diff --git a/README.ja.md b/README.ja.md index fbf24ec72..ff0943adf 100644 --- a/README.ja.md +++ b/README.ja.md @@ -192,7 +192,7 @@ npx qrcode "opencode://connect?name=Mac%20mini&url=http%3A%2F%2F192.168.1.10%3A4 4. 両方のSHA-256チェックサムを検証 5. アプリ専用ディレクトリに展開 6. Alpine内にGit、bash、curl、ripgrep、CA証明書をインストール -7. `127.0.0.1:4097`でエージェントサーバーを起動 +7. `127.0.0.1:4098`でエージェントサーバーを起動 8. アプリをローカルランタイムに切り替え 固定バージョン(エージェント自体を変更せずアプリのリリースで更新可能。詳細は[`local-runtime-manifest.json`](app/src/main/assets/local-runtime-manifest.json)を参照): diff --git a/README.md b/README.md index b20841497..48de0707d 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ The setup process (triggered from Workspaces): 4. Validates SHA-256 checksums for both 5. Extracts to a private app directory 6. Installs Git, bash, curl, ripgrep, and CA certificates inside Alpine -7. Starts the agent server on `127.0.0.1:4097` +7. Starts the agent server on `127.0.0.1:4098` 8. Switches the app to the local runtime Pinned versions (updatable via app releases without agent changes; see [`local-runtime-manifest.json`](app/src/main/assets/local-runtime-manifest.json)): diff --git a/app/src/main/assets/legal/auth_data_flow.ja.md b/app/src/main/assets/legal/auth_data_flow.ja.md index a4b019d27..9dabedcd4 100644 --- a/app/src/main/assets/legal/auth_data_flow.ja.md +++ b/app/src/main/assets/legal/auth_data_flow.ja.md @@ -10,7 +10,7 @@ [`app/src/main/assets/local-runtime-manifest.json`](../app/src/main/assets/local-runtime-manifest.json) 内の`openCodeUrl`、`sha256`で固定されています。 - **インストール場所:** SHA-256検証後、`LocalRuntimeInstaller`/`LocalOpenCodeBackend`により、アプリ専用の端末内Alpine rootfsへ展開されます。 -- **起動方法:** Alpine PRootサンドボックス内で`127.0.0.1:4097`にバインドされたローカルHTTPサーバーとして起動します(`LocalOpenCodeBackend`)。AndCodeは他のOpenCodeクライアントと同様に`OpenCodeApiClient`経由で通信します。**リモートOpenCode**の場合、AndCodeは代わりに、利用者が既に自身のPC/Mac/Linuxで実行しているOpenCodeサーバーへ、入力されたURL/ユーザー名/パスワードを用いて接続します(`RemoteOpenCodeBackend`)。 +- **起動方法:** Alpine PRootサンドボックス内で`127.0.0.1:4098`にバインドされたローカルHTTPサーバーとして起動します(`LocalOpenCodeBackend`)。AndCodeは他のOpenCodeクライアントと同様に`OpenCodeApiClient`経由で通信します。**リモートOpenCode**の場合、AndCodeは代わりに、利用者が既に自身のPC/Mac/Linuxで実行しているOpenCodeサーバーへ、入力されたURL/ユーザー名/パスワードを用いて接続します(`RemoteOpenCodeBackend`)。 - **認証方法:** OpenCode自身のプロバイダー認証(例:AnthropicやOpenAIのAPIキー、あるいはプロバイダー自身のOAuthフロー)は、OpenCodeサーバー自身が処理します。AndCodeの`ProviderAuthDialog`/`SettingsViewModel`は、OpenCode APIの`providerAuthMethods`/`authorizeProvider`/`completeProviderOAuth`エンドポイントを呼び出し、サーバーが返す方式・URL・入力項目をそのまま表示します。資格情報のやり取り自体は、OpenCodeプロセスとプロバイダーの間で行われ、AndCodeの内部では行われません。 - **認証URL/コードの扱い:** プロバイダーの認証方式がブラウザURLを開くものである場合、AndCodeはAndroidの`Intent.ACTION_VIEW`でそれを開き、入力されたコードをOpenCode APIへ送り返します。完全な認証URLは永続的なログへ書き込まれません(ログに記録する前に、`SecretRedaction.redactUrlQuery`によりクエリ文字列が除去されます)。 - **OAuth/APIキーの保存主体 — 単一ではなく3つの異なる経路:** diff --git a/app/src/main/assets/legal/auth_data_flow.md b/app/src/main/assets/legal/auth_data_flow.md index c5c43563f..c27803d19 100644 --- a/app/src/main/assets/legal/auth_data_flow.md +++ b/app/src/main/assets/legal/auth_data_flow.md @@ -14,7 +14,7 @@ that implement each flow, not from how the feature is expected to behave. (`openCodeUrl`, `sha256`). - **Install location:** extracted into the app's private on-device Alpine rootfs by `LocalRuntimeInstaller`/`LocalOpenCodeBackend`, after SHA-256 verification. -- **Launch:** started as a local HTTP server bound to `127.0.0.1:4097` inside the Alpine PRoot +- **Launch:** started as a local HTTP server bound to `127.0.0.1:4098` inside the Alpine PRoot sandbox (`LocalOpenCodeBackend`); AndCode talks to it over `OpenCodeApiClient` like any OpenCode client would. For **remote OpenCode**, AndCode instead connects to an OpenCode server you already run on your own PC/Mac/Linux (`RemoteOpenCodeBackend`), given a URL/username/password you supply. diff --git a/app/src/main/assets/local-runtime-manifest.json b/app/src/main/assets/local-runtime-manifest.json index 0e7c0e4c0..dbd74ace8 100644 --- a/app/src/main/assets/local-runtime-manifest.json +++ b/app/src/main/assets/local-runtime-manifest.json @@ -3,7 +3,7 @@ "runtimeVersion": "2026.07.25.1", "openCodeVersion": "1.18.5", "alpineVersion": "3.24.1", - "port": 4097, + "port": 4098, "architectures": { "arm64-v8a": { "alpineUrl": "https://dl-cdn.alpinelinux.org/alpine/v3.24/releases/aarch64/alpine-minirootfs-3.24.1-aarch64.tar.gz", diff --git a/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt b/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt index 9d3c0a588..3f469868b 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt @@ -634,6 +634,7 @@ class AndCodeApplication : Application() { status = localRuntimeManager.status(), idleStopInProgress = idleStopInProgress.value, userStoppedRuntime = settings.localRuntimeStoppedByUser, + interruptedActivationPending = localRuntimeInstaller.hasInterruptedActivation(), ) if (shouldRestore) { RuntimeAutoStartInitializer.restoreIfConfigured(this@AndCodeApplication, RuntimeAutoStartTrigger.AppLaunch) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index 7c73ab112..0dc7f7a23 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -262,6 +262,8 @@ fun ChatHomeScreen( onLoadDraft: (String) -> String? = { null }, /** Keeps the composer's unsent text for a session; blank text clears it. */ onSaveDraft: (String, String) -> Unit = { _, _ -> }, + localRuntimeStoppedManually: Boolean = false, + onStartLocalRuntime: () -> Unit = {}, ) { // Saved state, not plain remember: the Activity is recreated on rotation and other // configuration changes, and a remembered field would come back empty. The TextFieldValue @@ -470,6 +472,10 @@ fun ChatHomeScreen( ), ) + if (localRuntimeStoppedManually) { + LocalRuntimeStoppedBanner(onStart = onStartLocalRuntime) + } + state.parentSession?.let { parent -> SubagentSessionBanner( parentTitle = parent.title, @@ -909,6 +915,41 @@ private fun SubagentSessionBanner( } } +@Composable +private fun LocalRuntimeStoppedBanner(onStart: () -> Unit) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 2.dp) + .testTag("local-runtime-stopped-banner"), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.runtime_stopped_manually_title), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(R.string.runtime_stopped_manually_hint), + style = MaterialTheme.typography.labelSmall, + ) + } + Button(onClick = onStart) { + Text(stringResource(R.string.runtime_start_button)) + } + } + } +} + @Composable private fun StickyTodoBar( todos: List, @@ -2367,3 +2408,11 @@ private fun ChatHomeScreenCodexFastPreview() { ) } } + +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun LocalRuntimeStoppedBannerPreview() { + AndCodeTheme { + LocalRuntimeStoppedBanner(onStart = {}) + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/onboarding/AndroidSetupScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/onboarding/AndroidSetupScreen.kt index c6379fa8c..3cc636dc4 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/onboarding/AndroidSetupScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/onboarding/AndroidSetupScreen.kt @@ -1373,7 +1373,7 @@ private fun AndroidSetupScreenPreview() { private fun AndroidSetupProviderStepPreview() { AndCodeTheme { AndroidSetupScreen( - runtimeStatus = LocalRuntimeStatus.Ready("1.0.0", 4097), + runtimeStatus = LocalRuntimeStatus.Ready("1.0.0", 4098), claude = ClaudeCodeUiState(installed = true, version = "2.1.212"), onStartSetup = { _, _ -> }, onBeginClaudeSignIn = {}, diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/IdleStopGate.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/IdleStopGate.kt new file mode 100644 index 000000000..65ce316e8 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/IdleStopGate.kt @@ -0,0 +1,44 @@ +package com.yugahashimoto.andcode.runtime.local + +/** + * Keeps the watchdog's idle auto-stop from overriding a newer command intent. + * + * Every command the service receives calls [onCommand], which advances a generation. An idle stop + * takes its token from [beginIdleStop] at the generation it was decided at, and [isCurrent] reports + * whether nothing newer has arrived since. The stop only tears the service down while that still + * holds; a start that arrives in the meantime wins and the service stays up. + * + * Kept free of Android types so the interleavings can be tested directly. + */ +internal class IdleStopGate { + private val lock = Any() + private var generation = 0L + private var pendingToken: Long? = null + + /** Records a newer command intent and returns the generation it produced. */ + fun onCommand(): Long = + synchronized(lock) { + generation++ + generation + } + + /** Starts an idle stop decision. Returns its token, or null when one is already pending. */ + fun beginIdleStop(): Long? = + synchronized(lock) { + if (pendingToken != null) { + null + } else { + generation.also { pendingToken = it } + } + } + + /** Whether no command has arrived since [token] was taken. */ + fun isCurrent(token: Long): Boolean = synchronized(lock) { generation == token } + + /** Clears the pending idle stop for [token]. A token that is no longer pending is ignored. */ + fun finishIdleStop(token: Long) { + synchronized(lock) { + if (pendingToken == token) pendingToken = null + } + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeInstaller.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeInstaller.kt index 863113221..7a7a0bd74 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeInstaller.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeInstaller.kt @@ -240,6 +240,16 @@ class LocalRuntimeInstaller( } } + /** + * Whether an install or update was interrupted and left state that the start path repairs: a + * rollback copy of the environment (fixed by [recoverInterruptedActivation]) or an in-app update + * journal (fixed by the update engine's recover step in the manager's start). A plain + * file-existence check, so it is cheap enough for the foreground-return path; it takes no lock. + */ + fun hasInterruptedActivation(): Boolean = + File(runtimeDirectory, "environment.rollback").isDirectory || + INTERRUPTED_UPDATE_JOURNALS.any { name -> File(runtimeDirectory, name).isFile } + fun recoverInterruptedActivation(): Boolean = accessCoordinator.write { recoverInterruptedRuntimeEnvironment( @@ -259,6 +269,7 @@ class LocalRuntimeInstaller( accessCoordinator.write { val active = File(runtimeDirectory, "environment") normalizeRuntimeSymlinks(active) + migrateLegacyRuntimePort(runtimeDirectory, manifestReader::port, json) val metadataFile = File(runtimeDirectory, METADATA_FILE) val rootfs = File(active, "rootfs") if (!metadataFile.isFile || !rootfs.isDirectory) return@write null @@ -685,6 +696,13 @@ class LocalRuntimeInstaller( internal companion object { private const val METADATA_FILE = "metadata.json" + + /** + * Journals [LocalRuntimeUpdater] leaves behind when an update or rollback is interrupted. + * Mirrors the names in its companion; [LocalRuntimeManager.startLocked] runs its recover() + * for these, so they count as an interrupted activation. + */ + private val INTERRUPTED_UPDATE_JOURNALS = listOf("update-transaction.json", "rollback-transaction.json") private const val BROWSER_MCP_NAME = "and-code-browser" private const val BROWSER_MCP_BIN = "/usr/local/bin/andcode-browser-mcp.py" private const val BROWSER_MCP_TIMEOUT_MILLIS = 30000 diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManager.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManager.kt index d96090557..f7462bd77 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManager.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManager.kt @@ -50,6 +50,13 @@ class LocalRuntimeManager( private val runtimeDirectory: File, private val abi: String, private val portProbe: (Int) -> Boolean = ::defaultPortProbe, + /** Whether the server answering on a port is ours; see [defaultServerOwnershipProbe]. */ + private val ownershipProbe: (Int, String) -> Boolean = ::defaultServerOwnershipProbe, + /** + * Whether our own OpenCode server proot is alive, by scanning /proc. Only the server counts: + * Claude Code, Codex and terminal sessions run proot from the same runtime directory too. + */ + private val managedRuntimeRunning: () -> Boolean = { findOpenCodeServerPids(runtimeDirectory).isNotEmpty() }, private val installer: LocalRuntimeInstaller? = null, private val processLauncher: LocalRuntimeProcessLauncher? = null, /** Whether the server process we started is still alive, independent of whether it answers. */ @@ -74,6 +81,12 @@ class LocalRuntimeManager( encodeDefaults = true } private val operationMutex = Mutex() + + /** + * The port the start path last found held by a server that is not ours, or null. Lets + * [computeStatus] report that port as Broken without probing it on every status read. + */ + @Volatile private var foreignPortOwner: Int? = null private val mutableState = MutableStateFlow(computeStatus()) val state: StateFlow = mutableState.asStateFlow() @@ -172,16 +185,33 @@ class LocalRuntimeManager( suspend fun stop(): Result = operationMutex.withLock { - runCatching { - withContext(Dispatchers.IO) { processLauncher?.stop() } - val metadata = readMetadata() ?: error("Local runtime metadata is missing") - LocalRuntimeStatus.Stopped(metadata.version, metadata.port).also { mutableState.value = it } - }.onFailure { error -> - mutableState.value = - LocalRuntimeStatus.Broken( - error.message ?: messages.stopFailed, - ) - } + stopLocked() + } + + /** + * Stops the runtime only if [stillWanted] still holds once the operation lock is acquired. + * + * Used by the idle auto-stop: it can queue behind a newer start, and checking its own + * conditions before queueing would miss a start that lands while it waits. Returns false, and + * leaves the runtime alone, when [stillWanted] no longer holds. + */ + suspend fun stopIfStillWanted(stillWanted: () -> Boolean): Boolean = + operationMutex.withLock { + if (!stillWanted()) return@withLock false + stopLocked() + true + } + + private suspend fun stopLocked(): Result = + runCatching { + withContext(Dispatchers.IO) { processLauncher?.stop() } + val metadata = readMetadata() ?: error("Local runtime metadata is missing") + LocalRuntimeStatus.Stopped(metadata.version, metadata.port).also { mutableState.value = it } + }.onFailure { error -> + mutableState.value = + LocalRuntimeStatus.Broken( + error.message ?: messages.stopFailed, + ) } suspend fun deleteRuntime(): Result = @@ -596,7 +626,17 @@ class LocalRuntimeManager( runCatching { installer?.provisionGuestCapabilitiesForExistingInstall() } // Before the server starts, so its first turn already reads the selected preset. applyOpenCodeSystemPrompt(installed.rootfs, systemPrompt()) - if (!portProbe(installed.metadata.port)) launcher.start(installed) + if (!portProbe(installed.metadata.port)) { + launcher.start(installed) + } else if (!processAlive() && !ownsPort(installed.metadata.port)) { + // Something else holds the port: launching would fail, and reporting Ready would + // point the chat at a foreign server. Surface it instead; never kill it. + foreignPortOwner = installed.metadata.port + val reason = messages.portInUse(installed.metadata.port) + mutableState.value = LocalRuntimeStatus.Broken(reason) + error(reason) + } + foreignPortOwner = null val ready = LocalRuntimeStatus.Ready( installed.metadata.version, @@ -606,6 +646,18 @@ class LocalRuntimeManager( ready } + /** + * Whether the server on [port] is ours. Only called from the start path, where an open port + * would otherwise be taken as proof of a running runtime, so the blocking probe is acceptable. + * + * Our own proot process counts as ours without probing: that is the server we started, merely + * slow to answer. The probe is reserved for a listener this app did not launch. + */ + private fun ownsPort(port: Int): Boolean { + if (managedRuntimeRunning()) return true + return ownershipProbe(port, LocalServerAuth.password(runtimeDirectory)) + } + private fun computeStatus(): LocalRuntimeStatus { if (abi !in SUPPORTED_ABIS) return LocalRuntimeStatus.UnsupportedAbi(abi) val metadataFile = File(runtimeDirectory, METADATA_FILE) @@ -627,7 +679,13 @@ class LocalRuntimeManager( if (metadata.version.isBlank() || metadata.port !in 1..65535) { return LocalRuntimeStatus.Broken("Runtime metadata contains invalid values") } - if (portProbe(metadata.port)) return LocalRuntimeStatus.Ready(metadata.version, metadata.port) + if (portProbe(metadata.port)) { + // Open, but not ours, if the start path found a foreign server there. Cached rather than + // probed here: this runs often, and an HTTP probe must not sit on that path. + if (foreignPortOwner == metadata.port) return LocalRuntimeStatus.Broken(messages.portInUse(metadata.port)) + return LocalRuntimeStatus.Ready(metadata.version, metadata.port) + } + foreignPortOwner = null // A missed probe is not proof of death. Our own child process is: if it is still running, // the server exists and is merely too busy to answer, and restarting it would destroy work // in progress rather than recover anything. diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManifest.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManifest.kt index 7728dfc48..f1d08d658 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManifest.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManifest.kt @@ -54,6 +54,9 @@ class LocalRuntimeManifestReader( isLenient = true }, ) { + /** The local server port this build uses; see [LocalRuntimeManifest.port]. */ + fun port(): Int = read().port + fun read(): LocalRuntimeManifest { val payload = context.assets.open(ASSET_NAME).bufferedReader().use { it.readText() } return json.decodeFromString(payload).also { it.validate() } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeMessages.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeMessages.kt index cb6254997..41d10bad9 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeMessages.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeMessages.kt @@ -68,6 +68,9 @@ interface LocalRuntimeMessages { reason: String, ): String + /** Shown when the local port is held by a server that is not ours. */ + fun portInUse(port: Int): String + /** * English fallbacks, used by unit tests and by any construction path that has no [Context]. * Production wiring always injects [AndroidLocalRuntimeMessages]. @@ -129,6 +132,8 @@ interface LocalRuntimeMessages { version: String, reason: String, ) = "Restored OpenCode $version but it will not start: $reason" + + override fun portInUse(port: Int) = "Port $port is in use by another process" } } @@ -189,4 +194,6 @@ class AndroidLocalRuntimeMessages(private val context: Context) : LocalRuntimeMe version: String, reason: String, ): String = context.getString(R.string.runtime_error_restored_but_cannot_start, version, reason) + + override fun portInUse(port: Int): String = context.getString(R.string.runtime_error_port_in_use, port) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimePort.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimePort.kt new file mode 100644 index 000000000..52ce0e446 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimePort.kt @@ -0,0 +1,81 @@ +package com.yugahashimoto.andcode.runtime.local + +import com.yugahashimoto.andcode.core.api.OpenCodeHealth +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.util.Base64 + +/** + * The port the runtime listened on before this fork moved to its own port. The port is copied into + * the metadata at install time, so an existing install keeps it until [migrateLegacyRuntimePort]. + */ +internal const val LEGACY_LOCAL_RUNTIME_PORT = 4097 + +private const val METADATA_FILE_NAME = "metadata.json" +private const val OWNERSHIP_TIMEOUT_MILLIS = 2_000 +private const val HTTP_OK = 200 +private val OWNERSHIP_JSON = + Json { + ignoreUnknownKeys = true + isLenient = true + } + +/** + * Moves an install recorded on [LEGACY_LOCAL_RUNTIME_PORT] to the manifest's port. Only that exact + * legacy value is rewritten, so any other port is left alone. The target is read lazily so a + * metadata file that needs no migration never touches the manifest asset. Returns true when the + * metadata was rewritten. + */ +internal fun migrateLegacyRuntimePort( + runtimeDirectory: File, + targetPort: () -> Int, + json: Json, +): Boolean { + val metadataFile = File(runtimeDirectory, METADATA_FILE_NAME) + if (!metadataFile.isFile) return false + val metadata = + runCatching { json.decodeFromString(metadataFile.readText()) }.getOrNull() + ?: return false + if (metadata.port != LEGACY_LOCAL_RUNTIME_PORT) return false + val port = targetPort() + if (port == LEGACY_LOCAL_RUNTIME_PORT || port !in 1..65535) return false + val staged = File(runtimeDirectory, "$METADATA_FILE_NAME.port-migration") + try { + staged.writeText(json.encodeToString(metadata.copy(port = port))) + replaceFileAtomically(staged, metadataFile) + } finally { + staged.delete() + } + return true +} + +/** + * Whether the server answering on [port] is our OpenCode, judged by an authenticated + * `global/health` request. A rejected password, a non-OpenCode answer, a refused or timed-out + * connection all count as not ours. Blocking: call it off the main thread. + */ +internal fun defaultServerOwnershipProbe( + port: Int, + password: String, +): Boolean = + runCatching { + val connection = URL("http://127.0.0.1:$port/global/health").openConnection() as HttpURLConnection + try { + connection.connectTimeout = OWNERSHIP_TIMEOUT_MILLIS + connection.readTimeout = OWNERSHIP_TIMEOUT_MILLIS + connection.requestMethod = "GET" + val credentials = "${LocalServerAuth.USERNAME}:$password".toByteArray(Charsets.UTF_8) + connection.setRequestProperty("Authorization", "Basic " + Base64.getEncoder().encodeToString(credentials)) + if (connection.responseCode != HTTP_OK) { + false + } else { + val body = connection.inputStream.bufferedReader().use { it.readText() } + OWNERSHIP_JSON.decodeFromString(body).healthy + } + } finally { + connection.disconnect() + } + }.getOrDefault(false) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncher.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncher.kt index 79691c6a3..4681368ae 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncher.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncher.kt @@ -283,6 +283,25 @@ internal fun findManagedRuntimeRootPids( .toList() } +/** + * The subset of [findManagedRuntimeRootPids] that is the OpenCode server this launcher starts + * (`/usr/local/bin/opencode serve`), not another agent's or a terminal's proot in the same runtime. + */ +internal fun findOpenCodeServerPids( + runtimeDirectory: File, + procRoot: File = File("/proc"), +): List = + findManagedRuntimeRootPids(runtimeDirectory, procRoot).filter { pid -> + val arguments = + runCatching { + File(File(procRoot, pid.toString()), "cmdline").readBytes().toString(Charsets.UTF_8).split('\u0000') + }.getOrDefault(emptyList()) + val binary = arguments.indexOf(OPEN_CODE_SERVER_BINARY) + binary >= 0 && arguments.getOrNull(binary + 1) == "serve" + } + +private const val OPEN_CODE_SERVER_BINARY = "/usr/local/bin/opencode" + internal fun processId(process: Process): Long? { val methodValue = runCatching { diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeService.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeService.kt index 3c4df820a..5b0f04265 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeService.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeService.kt @@ -203,6 +203,30 @@ internal fun shouldStopIdleRuntime( !adbConnected && idleForMillis >= timeoutMillis +/** Lease tag held by the idle auto-stop itself; it must not count as work that blocks that stop. */ +internal const val IDLE_STOP_LEASE_TAG = "idle-stop" + +/** + * Whether the idle stop decided earlier still applies, evaluated just before the runtime is stopped. + * + * [activeTags] is the set of leases currently held. The idle stop holds its own lease under + * [IDLE_STOP_LEASE_TAG] while it runs, so that tag is ignored here: otherwise the stop would always + * see itself as work and decline. Anything else in [activeTags] is real work and blocks the stop. + * [idleStopRequested] is false when a newer command superseded the decision. + */ +internal fun idleStopStillApplies( + idleStopRequested: Boolean, + status: LocalRuntimeStatus, + activeTags: Set, + appInForeground: Boolean, + adbConnected: Boolean, +): Boolean = + idleStopRequested && + status is LocalRuntimeStatus.Ready && + (activeTags - IDLE_STOP_LEASE_TAG).isEmpty() && + !appInForeground && + !adbConnected + internal class RestartBackoff( private val nowMillis: () -> Long = System::currentTimeMillis, private val baseDelayMs: Long = 1_000L, @@ -262,6 +286,11 @@ internal class IdleStopTracker(private val nowMillis: () -> Long = SystemClock:: idleSinceMillis = if (idleNow) idleSinceMillis ?: now else null return idleSinceMillis?.let { now - it } ?: 0L } + + /** Restarts the idle clock, so a declined stop waits a full timeout before it is attempted again. */ + fun reset() { + idleSinceMillis = null + } } /** @@ -304,10 +333,15 @@ class LocalRuntimeService : Service() { private var shuttingDown = false /** - * Read and written only from [startWatchdog]'s own coroutine, so it needs no synchronization of - * its own despite [IdleStopTracker] not providing any. + * Written from [startWatchdog]'s coroutine and, on a declined stop, from the main thread via + * [finishIdleStop]. [IdleStopTracker] does no locking of its own, so both writers go through + * [idleTrackerLock]. */ private val idleTracker = IdleStopTracker() + private val idleTrackerLock = Any() + + /** Orders idle-stop decisions against incoming commands; see [IdleStopGate]. */ + private val idleStopGate = IdleStopGate() override fun onCreate() { super.onCreate() @@ -362,6 +396,10 @@ class LocalRuntimeService : Service() { startId: Int, ): Int { val command = localRuntimeServiceCommand(intent?.action) + // A real command supersedes an idle stop decided earlier; see [IdleStopGate]. Recorded before + // anything else so an idle stop that completes from here on cannot tear the service down. + // Ignore is excluded: it changes nothing, so it must not leave the service up with no owner. + if (command != LocalRuntimeServiceCommand.Ignore) idleStopGate.onCommand() // An explicit start always means "the user (or something acting for them) wants this // running" - without clearing the flag here, a deliberate restart from the workspace screen // would still read as "leave it down" the very next time the app comes to the foreground. @@ -521,41 +559,101 @@ class LocalRuntimeService : Service() { hasActiveWork: Boolean, ): Boolean { val appInForeground = appForeground.foreground.value - val adbConnected = app.adbConnectionManager.state.value is AdbConnectionState.Connected + val adbConnected = adbConnected() val idleNow = status is LocalRuntimeStatus.Ready && !hasActiveWork && !appInForeground && !adbConnected - val idleForMillis = idleTracker.update(idleNow) + val idleForMillis = synchronized(idleTrackerLock) { idleTracker.update(idleNow) } if (!app.settings.localRuntimeIdleStopEnabled) return false if (!shouldStopIdleRuntime(status, hasActiveWork, appInForeground, adbConnected, idleForMillis, IDLE_STOP_TIMEOUT_MILLIS)) { return false } + val token = idleStopGate.beginIdleStop() ?: return false + val previousAutoRestart = autoRestartEnabled + // Set by the stop step when it was declined because the runtime became busy or came back to + // the foreground while the idle stop waited for the manager lock. + var declined = false + // Set synchronously, before the coroutine below exists. The in-progress flag is what + // shouldRestoreOnForegroundReturn reads: setting it only inside the coroutine left a window + // where a foreground return saw Ready and not-in-progress, skipped the restore, and then the + // stop ran with nothing left to bring the runtime back. autoRestartEnabled is cleared for the + // same reason, so the watchdog cannot restart the runtime being stopped. autoRestartEnabled = false + app.setIdleStopInProgress(true) // Deliberately not launchOperation: this is the watchdog's own decision, not a user or - // notification action, so it must not cancel anything already tracked in `operation`. The - // set/clear pair for setIdleStopInProgress is extracted into runIdleStopSequence precisely so - // both live inside one coroutine body rather than straddling this launch call: if onDestroy - // (and therefore scope.cancel()) lands between scheduling this coroutine and it actually - // starting, the body - set included - simply never runs, rather than setting the flag and - // then never reaching the finally that clears it. A stuck `true` would make - // shouldRestoreOnForegroundReturn treat every later foreground entry as needing a restore, - // sending ACTION_START at a healthy Ready runtime and cancelling whatever operation it finds - // in flight. onDestroy also clears the flag directly as a belt-and-braces guard for any - // window this reasoning misses. + // notification action, so it must not cancel anything already tracked in `operation`. + // onDestroy clears the flag as well, so a scope cancelled before this coroutine starts + // cannot leave it stuck at true. scope.launch(Dispatchers.IO) { - runtimeWork.withLease(RUNTIME_OPERATION_LEASE_TAG) { + // Under its own tag, so the stop's own lease is never mistaken for work by stillIdleAt. + runtimeWork.withLease(IDLE_STOP_LEASE_TAG) { runIdleStopSequence( markInProgress = app::setIdleStopInProgress, - stop = manager::stop, - onStopped = { - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() + stop = { + // Checked inside the manager's lock: this can queue behind a newer start, + // and that start must win rather than be stopped right after it finishes. + val stopped = manager.stopIfStillWanted { stillIdleAt(token) } + if (!stopped) declined = true }, + onStopped = { finishIdleStop(token, previousAutoRestart, declined) }, ) } } return true } + /** + * Whether the idle stop decided at [token] still applies. Evaluated under the manager lock, so + * a start that finished meanwhile is seen as a non-Ready state or as work, and the stop declines. + */ + private fun stillIdleAt(token: Long): Boolean = + idleStopStillApplies( + idleStopRequested = idleStopGate.isCurrent(token), + status = manager.status(), + activeTags = runtimeWork.activeTags.value, + appInForeground = appForeground.foreground.value, + adbConnected = adbConnected(), + ) + + private fun adbConnected(): Boolean = app.adbConnectionManager.state.value is AdbConnectionState.Connected + + /** + * Runs once the idle stop has finished, whether or not it stopped the runtime. Tears the service + * down only when no newer command arrived in the meantime. Otherwise that command owns the + * service: it stays up, and the watchdog is restarted for it. + */ + private fun finishIdleStop( + token: Long, + previousAutoRestart: Boolean, + declined: Boolean, + ) { + // Hops to main before deciding: onStartCommand runs there, so the superseded check and the + // teardown happen in one turn that no command can interleave with. Deciding on IO and then + // hopping left a window where a start landed between the check and stopSelf(). + scope.launch(Dispatchers.Main.immediate) { + idleStopGate.finishIdleStop(token) + if (!idleStopGate.isCurrent(token)) { + // A newer command ran; it set its own auto-restart value, which must not be + // overwritten. The in-progress flag is cleared here too, so a superseded stop never + // leaves it stuck. The idle timer restarts as on a decline, so a runtime that is still + // idle afterwards is not stopped again on the very next tick. + app.setIdleStopInProgress(false) + synchronized(idleTrackerLock) { idleTracker.reset() } + startWatchdog() + } else if (declined) { + // Declined because the runtime became busy or was foregrounded: keep it up and the + // watchdog running, handing the auto-restart flag back. The idle timer restarts too, + // so a still-idle runtime is not retried on every tick. + autoRestartEnabled = previousAutoRestart + app.setIdleStopInProgress(false) + synchronized(idleTrackerLock) { idleTracker.reset() } + startWatchdog() + } else { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + } + /** * Holds or drops the CPU wake lock to match [status] and [hasActiveWork]; see * [localRuntimeNeedsWakeLock]. diff --git a/app/src/main/java/com/yugahashimoto/andcode/startup/RuntimeAutoStartPolicy.kt b/app/src/main/java/com/yugahashimoto/andcode/startup/RuntimeAutoStartPolicy.kt index 7892af29b..bfe5fda86 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/startup/RuntimeAutoStartPolicy.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/startup/RuntimeAutoStartPolicy.kt @@ -69,4 +69,7 @@ internal fun shouldRestoreOnForegroundReturn( status: LocalRuntimeStatus, idleStopInProgress: Boolean, userStoppedRuntime: Boolean, -): Boolean = !userStoppedRuntime && (status is LocalRuntimeStatus.Stopped || idleStopInProgress) + interruptedActivationPending: Boolean = false, +): Boolean = + !userStoppedRuntime && + (status is LocalRuntimeStatus.Stopped || idleStopInProgress || (status is LocalRuntimeStatus.Broken && interruptedActivationPending)) diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt index 033f6d93a..e9dfa449b 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt @@ -98,7 +98,9 @@ import com.yugahashimoto.andcode.feature.settings.SettingsViewModel import com.yugahashimoto.andcode.feature.wakeword.WakeWordService import com.yugahashimoto.andcode.feature.wakeword.WakeWordSettingsPolicy import com.yugahashimoto.andcode.feature.workspace.WorkspaceViewModel +import com.yugahashimoto.andcode.runtime.LocalRuntimeStatus import com.yugahashimoto.andcode.runtime.RuntimeState +import com.yugahashimoto.andcode.runtime.RuntimeType import com.yugahashimoto.andcode.runtime.WorkspaceRef import com.yugahashimoto.andcode.runtime.local.GitCloneResult import com.yugahashimoto.andcode.runtime.local.LocalRuntimeOperationResult @@ -227,6 +229,9 @@ fun AndCodeApp( }, ) val workspaceState by workspaceViewModel.state.collectAsState() + // The preference has no flow; the workspace state already observes every runtime status change. + val localRuntimeStoppedByUser = + remember(workspaceState.localStatus) { app.settings.localRuntimeStoppedByUser } val activityViewModel: ActivityViewModel = viewModel( @@ -1072,6 +1077,12 @@ fun AndCodeApp( selectedAgentId = chatState.selectedAgentId ?: settingsState.agentId, runtimeTargets = runtimeTargets, selectedRuntimeId = selectedRuntime?.id, + localRuntimeStoppedManually = + selectedRuntime?.type == RuntimeType.LOCAL && + selectedRuntime?.agent == com.yugahashimoto.andcode.runtime.LocalAgent.OPEN_CODE && + workspaceState.localStatus is LocalRuntimeStatus.Stopped && + localRuntimeStoppedByUser, + onStartLocalRuntime = workspaceViewModel::startLocalRuntime, claudePermissionMode = workspaceState.claude .takeIf { selectedRuntime?.agent == com.yugahashimoto.andcode.runtime.LocalAgent.CLAUDE_CODE } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 416c8f3f3..0ba032a4a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -942,6 +942,7 @@ تعذّر تشغيل OpenCode بعد الرجوع انتهت مهلة التحقق تمت استعادة OpenCode %1$s لكنه لا يعمل: %2$s + المنفذ %1$d قيد الاستخدام من عملية أخرى جارٍ تحضير التحديث جارٍ الرجوع إلى OpenCode %1$s جارٍ تثبيت Claude Code @@ -1212,4 +1213,6 @@ تم تثبيت أدوات التطوير الكاملة تثبيت مجموعة الأدوات الكاملة تعذر بدء تثبيت أدوات التطوير + تم الإيقاف يدويًا + لن يبدأ OpenCode تلقائيًا حتى تشغّله يدويًا. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b6f12b3a4..947b1809f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -933,6 +933,7 @@ No se pudo iniciar OpenCode tras volver atrás La comprobación superó el tiempo límite Se restauró OpenCode %1$s pero no arranca: %2$s + El puerto %1$d está en uso por otro proceso Preparando la actualización Volviendo a OpenCode %1$s Instalando Claude Code @@ -1203,4 +1204,6 @@ Todas las herramientas de desarrollo están instaladas Instalar cadena completa No se pudo iniciar la instalación de herramientas + Detenido manualmente + OpenCode no se iniciará automáticamente hasta que lo inicies. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 15f1b5f62..76c1cc2f0 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -933,6 +933,7 @@ Impossible de démarrer OpenCode après le retour arrière La vérification a expiré OpenCode %1$s a été restauré mais ne démarre pas : %2$s + Le port %1$d est utilisé par un autre processus Préparation de la mise à jour Retour à OpenCode %1$s Installation de Claude Code @@ -1203,4 +1204,6 @@ Tous les outils de développement sont installés Installer la chaîne complète Impossible de démarrer l’installation des outils + Arrêté manuellement + OpenCode ne démarrera pas automatiquement tant que vous ne le démarrerez pas. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b004b80e3..6accc88ec 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -939,6 +939,7 @@ ロールバック後の起動に失敗しました 確認がタイムアウトしました OpenCode %1$sを復元しましたが起動できません: %2$s + ポート %1$d は別のプロセスが使用中です 更新を準備しています OpenCode %1$sへロールバックしています Claude Codeを導入しています @@ -1217,4 +1218,6 @@ 完全な開発ツールをインストール済み 完全なツールチェーンをインストール 開発ツールのインストールを開始できません + 手動で停止しました + 手動で起動するまで、OpenCodeは自動起動しません。 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index e697076d9..9a73ff29b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -933,6 +933,7 @@ Não foi possível iniciar o OpenCode após reverter A verificação excedeu o tempo limite OpenCode %1$s foi restaurado, mas não inicia: %2$s + A porta %1$d está em uso por outro processo Preparando a atualização Revertendo para o OpenCode %1$s Instalando o Claude Code @@ -1203,4 +1204,6 @@ Todas as ferramentas de desenvolvimento estão instaladas Instalar cadeia completa Não foi possível iniciar a instalação das ferramentas + Parado manualmente + O OpenCode não iniciará automaticamente até você iniciá-lo. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5d72e7eca..61c4adecb 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -936,6 +936,7 @@ Не удалось запустить OpenCode после отката Время проверки истекло OpenCode %1$s восстановлен, но не запускается: %2$s + Порт %1$d занят другим процессом Подготовка обновления Откат к OpenCode %1$s Установка Claude Code @@ -1206,4 +1207,6 @@ Полный набор инструментов установлен Установить полный набор Не удалось начать установку инструментов разработки + Остановлен вручную + OpenCode не будет запускаться автоматически, пока вы не запустите его вручную. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e7ffc0d42..2e9c49f9a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -927,6 +927,7 @@ 回退后无法启动 OpenCode 检查超时 已恢复 OpenCode %1$s,但无法启动:%2$s + 端口 %1$d 已被其他进程占用 正在准备更新 正在回退到 OpenCode %1$s 正在安装 Claude Code @@ -1197,4 +1198,6 @@ 完整开发工具已安装 安装完整工具链 无法开始安装开发工具 + 已手动停止 + 在你手动启动前,OpenCode 不会自动启动。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c8564bdc..fc3126589 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -929,6 +929,7 @@ Could not start OpenCode after reverting The check timed out Restored OpenCode %1$s but it will not start: %2$s + Port %1$d is in use by another process Preparing the update Reverting to OpenCode %1$s Installing Claude Code @@ -1221,4 +1222,6 @@ Complete development tools are installed Install complete toolchain Could not start development tool installation + Stopped manually + OpenCode won\'t start automatically until you start it. diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/IdleStopDecisionTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/IdleStopDecisionTest.kt new file mode 100644 index 000000000..20a0a2470 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/IdleStopDecisionTest.kt @@ -0,0 +1,117 @@ +package com.yugahashimoto.andcode.runtime.local + +import com.yugahashimoto.andcode.runtime.LocalRuntimeStatus +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The pure "is the idle stop still wanted" decision, plus [LocalRuntimeManager.stopIfStillWanted] + * run against a real manager with a stubbed launcher, so the stop-or-leave outcome is observed on + * the runtime's state rather than re-derived in the test. + */ +class IdleStopDecisionTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private fun applies( + requested: Boolean = true, + status: LocalRuntimeStatus = LocalRuntimeStatus.Ready("1.18.5", 4098), + tags: Set = setOf(IDLE_STOP_LEASE_TAG), + foreground: Boolean = false, + adb: Boolean = false, + ): Boolean = + idleStopStillApplies( + idleStopRequested = requested, + status = status, + activeTags = tags, + appInForeground = foreground, + adbConnected = adb, + ) + + /** The stop's own lease must not block the stop it belongs to. */ + @Test + fun `its own idle-stop lease alone does not block the stop`() { + assertTrue(applies(tags = setOf(IDLE_STOP_LEASE_TAG))) + } + + /** Real work in another tag does block the stop, even alongside the idle-stop lease. */ + @Test + fun `other work alongside the idle-stop lease blocks the stop`() { + assertFalse(applies(tags = setOf(IDLE_STOP_LEASE_TAG, "sessions"))) + assertFalse(applies(tags = setOf("runtime-op"))) + } + + @Test + fun `a superseded decision does not apply`() { + assertFalse(applies(requested = false)) + } + + @Test + fun `a runtime that is no longer Ready does not apply`() { + assertFalse(applies(status = LocalRuntimeStatus.Starting("1.18.5", 4098))) + assertFalse(applies(status = LocalRuntimeStatus.Stopped("1.18.5", 4098))) + } + + @Test + fun `foreground or an attached adb link does not apply`() { + assertFalse(applies(foreground = true)) + assertFalse(applies(adb = true)) + } + + @Test + fun `an uncontested idle runtime applies`() { + assertTrue(applies()) + } + + private fun managerWith(portProbe: (Int) -> Boolean): LocalRuntimeManager { + val launcher = + LocalRuntimeProcessLauncher( + runtimeDirectory = temporaryFolder.root, + portProbe = portProbe, + procRoot = temporaryFolder.newFolder("proc"), + ) + return LocalRuntimeManager( + runtimeDirectory = temporaryFolder.root, + abi = "arm64-v8a", + portProbe = portProbe, + processLauncher = launcher, + ) + } + + private fun writeMetadata() { + File(temporaryFolder.root, "metadata.json").writeText("""{"version":"1.18.5","port":4098,"installedAt":123}""") + } + + /** A declined stop leaves the runtime untouched: no Stopped state is published. */ + @Test + fun `stopIfStillWanted returns false and leaves the runtime alone when no longer wanted`() = + runBlocking { + writeMetadata() + val manager = managerWith { true } + val before = manager.state.value + + val stopped = manager.stopIfStillWanted { false } + + assertFalse(stopped) + assertEquals(before, manager.state.value) + } + + /** A wanted stop goes ahead and publishes Stopped with the recorded version and port. */ + @Test + fun `stopIfStillWanted stops the runtime when still wanted`() = + runBlocking { + writeMetadata() + val manager = managerWith { true } + + val stopped = manager.stopIfStillWanted { true } + + assertTrue(stopped) + assertEquals(LocalRuntimeStatus.Stopped("1.18.5", 4098), manager.state.value) + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/IdleStopGateTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/IdleStopGateTest.kt new file mode 100644 index 000000000..d1b2fb5c8 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/IdleStopGateTest.kt @@ -0,0 +1,88 @@ +package com.yugahashimoto.andcode.runtime.local + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Interleavings of the watchdog's idle stop against an incoming command, reproduced on the + * [IdleStopGate] state machine without Android. The two regressions these guard against: + * + * 1. A foreground return landing after the idle stop decided to stop but before anything marked it + * in progress must still see the stop as pending, not skip the restore. + * 2. A start that arrives while an idle stop is still running must win: the finished stop may not + * tear the service down underneath it. + */ +class IdleStopGateTest { + @Test + fun `an idle stop with no newer command is still current when it completes`() { + val gate = IdleStopGate() + val token = gate.beginIdleStop() + assertNotNull(token) + + assertTrue(gate.isCurrent(token!!)) + gate.finishIdleStop(token) + } + + /** A start arriving while the idle stop waits on the manager lock supersedes it. */ + @Test + fun `a start that arrives during the idle stop supersedes it`() { + val gate = IdleStopGate() + val token = gate.beginIdleStop()!! + + gate.onCommand() + + assertFalse(gate.isCurrent(token)) + } + + /** Once superseded, a later idle stop decision is current again and is allowed to proceed. */ + @Test + fun `a fresh idle stop after a superseded one is current`() { + val gate = IdleStopGate() + val first = gate.beginIdleStop()!! + gate.onCommand() + gate.finishIdleStop(first) + + val second = gate.beginIdleStop() + + assertNotNull(second) + assertTrue(gate.isCurrent(second!!)) + } + + /** Only one idle stop may be pending at a time; a second decision is refused, not stacked. */ + @Test + fun `a second idle stop is refused while one is pending`() { + val gate = IdleStopGate() + assertNotNull(gate.beginIdleStop()) + + assertNull(gate.beginIdleStop()) + } + + /** A finish for a token that is no longer pending must not clear a newer pending stop. */ + @Test + fun `finishing a stale token does not clear a newer pending stop`() { + val gate = IdleStopGate() + val stale = gate.beginIdleStop()!! + gate.onCommand() + gate.finishIdleStop(stale) + val fresh = gate.beginIdleStop()!! + + gate.finishIdleStop(stale) + + assertNull(gate.beginIdleStop()) + assertTrue(gate.isCurrent(fresh)) + } + + /** The generation only moves forward, so tokens from before a command never match again. */ + @Test + fun `commands advance the generation monotonically`() { + val gate = IdleStopGate() + val first = gate.onCommand() + val second = gate.onCommand() + + assertEquals(first + 1, second) + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/IdleStopSequenceTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/IdleStopSequenceTest.kt index e0f4021b5..8e93b6e5b 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/IdleStopSequenceTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/IdleStopSequenceTest.kt @@ -1,5 +1,7 @@ package com.yugahashimoto.andcode.runtime.local +import com.yugahashimoto.andcode.runtime.LocalRuntimeStatus +import com.yugahashimoto.andcode.startup.shouldRestoreOnForegroundReturn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel @@ -7,6 +9,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -78,6 +81,53 @@ class IdleStopSequenceTest { assertTrue(events.isEmpty()) } + /** + * Interleaving 1: the watchdog decided to stop, then a foreground return arrives before the stop + * coroutine runs. The in-progress flag is set synchronously with the decision, so the real + * restore predicate treats the return as needing a restore. + */ + @Test + fun `a foreground return between the idle decision and the stop still restores`() { + assertTrue( + shouldRestoreOnForegroundReturn( + status = LocalRuntimeStatus.Ready("1.18.5", 4098), + idleStopInProgress = true, + userStoppedRuntime = false, + ), + ) + } + + /** + * Interleaving 2: a start lands while the idle stop waits for the manager lock. The real + * still-applies decision sees the newer command as superseding, so the stop is declined. + */ + @Test + fun `a start that lands during the idle stop supersedes it`() { + assertFalse( + idleStopStillApplies( + idleStopRequested = false, + status = LocalRuntimeStatus.Ready("1.18.5", 4098), + activeTags = setOf(IDLE_STOP_LEASE_TAG), + appInForeground = false, + adbConnected = false, + ), + ) + } + + /** The uncontested case still goes ahead: nothing newer arrived and the runtime is idle. */ + @Test + fun `an uncontested idle stop still applies`() { + assertTrue( + idleStopStillApplies( + idleStopRequested = true, + status = LocalRuntimeStatus.Ready("1.18.5", 4098), + activeTags = setOf(IDLE_STOP_LEASE_TAG), + appInForeground = false, + adbConnected = false, + ), + ) + } + /** * A cancellation landing mid-stop still has to clear the flag: the alternative is a service * that went away with `idleStopInProgress` stuck true, which makes every later foreground diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManagerTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManagerTest.kt index fd7fbed4c..61a025424 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManagerTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManagerTest.kt @@ -36,6 +36,7 @@ class LocalRuntimeManagerTest { runtimeDirectory = temporaryFolder.root, abi = "arm64-v8a", portProbe = { true }, + ownershipProbe = { _, _ -> true }, processLauncher = LocalRuntimeProcessLauncher(temporaryFolder.root, { true }), systemPrompt = { "Focus on debugging." }, ) @@ -59,6 +60,7 @@ class LocalRuntimeManagerTest { runtimeDirectory = temporaryFolder.root, abi = "arm64-v8a", portProbe = { true }, + ownershipProbe = { _, _ -> true }, processLauncher = LocalRuntimeProcessLauncher(temporaryFolder.root, { true }), systemPrompt = { null }, ) diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManagerUpdateTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManagerUpdateTest.kt index 10ff08afb..86d91e172 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManagerUpdateTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManagerUpdateTest.kt @@ -68,6 +68,7 @@ class LocalRuntimeManagerUpdateTest { runtimeDirectory = runtime, abi = "arm64-v8a", portProbe = { true }, + ownershipProbe = { _, _ -> true }, updateEngine = engine, runtimeOperations = operations, ) diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimePortTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimePortTest.kt new file mode 100644 index 000000000..0336ccd44 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimePortTest.kt @@ -0,0 +1,159 @@ +package com.yugahashimoto.andcode.runtime.local + +import com.yugahashimoto.andcode.runtime.LocalRuntimeStatus +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class LocalRuntimePortTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + private fun writeMetadata(port: Int) { + File(temporaryFolder.root, "metadata.json").writeText( + """{"version":"1.18.5","port":$port,"installedAt":123}""", + ) + } + + private fun readPort(): Int = json.decodeFromString(File(temporaryFolder.root, "metadata.json").readText()).port + + @Test + fun `a legacy 4097 install is moved to the manifest port`() { + writeMetadata(LEGACY_LOCAL_RUNTIME_PORT) + + val migrated = migrateLegacyRuntimePort(runtimeDirectory = temporaryFolder.root, targetPort = { 4098 }, json = json) + + assertTrue(migrated) + assertEquals(4098, readPort()) + } + + /** Only the exact legacy value is rewritten; a port someone else chose stays put. */ + @Test + fun `a non-legacy port is left alone`() { + writeMetadata(5555) + + assertFalse(migrateLegacyRuntimePort(runtimeDirectory = temporaryFolder.root, targetPort = { 4098 }, json = json)) + assertEquals(5555, readPort()) + } + + /** The manifest is only consulted when there is something to migrate. */ + @Test + fun `the manifest port is not read when no migration is needed`() { + writeMetadata(4098) + var consulted = false + + migrateLegacyRuntimePort( + runtimeDirectory = temporaryFolder.root, + targetPort = { + consulted = true + 4098 + }, + json = json, + ) + + assertFalse(consulted) + } + + @Test + fun `missing metadata is a no-op`() { + assertFalse(migrateLegacyRuntimePort(runtimeDirectory = temporaryFolder.root, targetPort = { 4098 }, json = json)) + } + + @Test + fun `the port-in-use reason names the port`() { + assertEquals("Port 4098 is in use by another process", LocalRuntimeMessages.portInUse(4098)) + } + + /** + * A port that is open but answered by a foreign server must not be reported Ready, and the + * launcher must not be invoked. Driven through [LocalRuntimeManager.startInstalled] with an + * ownership probe that says "not ours". + */ + @Test + fun `a foreign server on the port is reported broken and not treated as ready`() = + runBlocking { + val rootfs = temporaryFolder.newFolder("rootfs-foreign") + val manager = + LocalRuntimeManager( + runtimeDirectory = temporaryFolder.root, + abi = "arm64-v8a", + portProbe = { true }, + ownershipProbe = { _, _ -> false }, + managedRuntimeRunning = { false }, + processLauncher = LocalRuntimeProcessLauncher(temporaryFolder.root, { true }), + ) + + try { + manager.startInstalled(installedRuntime(rootfs, 4098)) + fail("expected the foreign port to be refused") + } catch (expected: IllegalStateException) { + assertEquals("Port 4098 is in use by another process", expected.message) + } + assertEquals( + LocalRuntimeStatus.Broken("Port 4098 is in use by another process"), + manager.state.value, + ) + } + + /** + * A proot we started is already on the port: it is ours, so the HTTP probe must not run. This + * is the slow-to-answer case the probe would otherwise misread as a foreign server. + */ + @Test + fun `a managed runtime already running is trusted without probing the port owner`() = + runBlocking { + val rootfs = temporaryFolder.newFolder("rootfs-owned") + var probed = false + val manager = + LocalRuntimeManager( + runtimeDirectory = temporaryFolder.root, + abi = "arm64-v8a", + portProbe = { true }, + ownershipProbe = { _, _ -> + probed = true + false + }, + managedRuntimeRunning = { true }, + processLauncher = LocalRuntimeProcessLauncher(temporaryFolder.root, { true }), + systemPrompt = { null }, + ) + + val ready = manager.startInstalled(installedRuntime(rootfs, 4098)) + + assertFalse(probed) + assertEquals("1.18.5", ready.version) + assertEquals(4098, ready.port) + } + + private fun installedRuntime( + rootfs: File, + port: Int, + ): LocalRuntimeInstaller.InstalledRuntime = + LocalRuntimeInstaller.InstalledRuntime( + metadata = LocalRuntimeMetadata(version = "1.18.5", port = port, installedAt = 123), + commandSuite = + EmbeddedCommandSuite.Paths( + home = rootfs, + tmp = rootfs, + nativeLibraryDirectory = rootfs, + proot = rootfs, + loader = rootfs, + loader32 = rootfs, + ), + rootfs = rootfs, + openCode = null, + ) +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncherTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncherTest.kt index d691b23e8..3e4de7808 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncherTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncherTest.kt @@ -440,6 +440,32 @@ class LocalRuntimeProcessLauncherTest { ) } + @Test + fun `counts only the OpenCode server among this runtime's proot processes`() { + val procRoot = temporaryFolder.newFolder("proc") + val runtimeDirectory = File("/data/user/0/com.doublepi123.andcode/files/runtime") + val rootfs = runtimeDirectory.resolve("environment/rootfs").path + val proot = EmbeddedCommandSuite.PROOT_LIBRARY_NAME + procRoot.resolve("100/cmdline").apply { + parentFile.mkdirs() + writeText( + "$proot\u0000-r\u0000$rootfs\u0000-w\u0000/workspace\u0000/usr/local/bin/opencode\u0000serve\u0000" + + "--hostname\u0000127.0.0.1\u0000--port\u00004098", + ) + } + // Codex's app-server runs from the same runtime directory and must not count as OpenCode. + procRoot.resolve("101/cmdline").apply { + parentFile.mkdirs() + writeText("$proot\u0000-r\u0000$rootfs\u0000/usr/local/bin/codex\u0000app-server") + } + procRoot.resolve("102/cmdline").apply { + parentFile.mkdirs() + writeText("$proot\u0000-r\u0000$rootfs\u0000/usr/local/bin/opencode\u0000run") + } + + assertEquals(listOf(100L), findOpenCodeServerPids(runtimeDirectory, procRoot)) + } + @Test fun `exit record starts null and restart count starts at zero`() { val launcher = diff --git a/app/src/test/java/com/yugahashimoto/andcode/startup/RuntimeAutoStartPolicyTest.kt b/app/src/test/java/com/yugahashimoto/andcode/startup/RuntimeAutoStartPolicyTest.kt index 12664e229..0bc03c681 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/startup/RuntimeAutoStartPolicyTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/startup/RuntimeAutoStartPolicyTest.kt @@ -144,6 +144,48 @@ class RuntimeAutoStartPolicyTest { ) } + /** + * An interrupted activation leaves the runtime reading as Broken from disk, and recovery lives + * in the start path - so a return has to be allowed to attempt the start in that case. + */ + @Test + fun `restores a broken runtime left by an interrupted activation`() { + assertTrue( + shouldRestoreOnForegroundReturn( + status = LocalRuntimeStatus.Broken("Runtime metadata is invalid"), + idleStopInProgress = false, + userStoppedRuntime = false, + interruptedActivationPending = true, + ), + ) + } + + /** A Broken runtime with no interrupted activation is not something a restart can repair. */ + @Test + fun `leaves a broken runtime alone when no activation was interrupted`() { + assertFalse( + shouldRestoreOnForegroundReturn( + status = LocalRuntimeStatus.Broken("Runtime environment is missing files"), + idleStopInProgress = false, + userStoppedRuntime = false, + interruptedActivationPending = false, + ), + ) + } + + /** A deliberate stop wins even over a pending interrupted-activation recovery. */ + @Test + fun `does not restore a user-stopped runtime even when an activation was interrupted`() { + assertFalse( + shouldRestoreOnForegroundReturn( + status = LocalRuntimeStatus.Broken("Runtime environment is missing files"), + idleStopInProgress = false, + userStoppedRuntime = true, + interruptedActivationPending = true, + ), + ) + } + @Test fun `leaves a healthy runtime alone on a foreground return`() { assertFalse( diff --git a/docs/AUTHENTICATION_AND_DATA_FLOW.md b/docs/AUTHENTICATION_AND_DATA_FLOW.md index c5c43563f..c27803d19 100644 --- a/docs/AUTHENTICATION_AND_DATA_FLOW.md +++ b/docs/AUTHENTICATION_AND_DATA_FLOW.md @@ -14,7 +14,7 @@ that implement each flow, not from how the feature is expected to behave. (`openCodeUrl`, `sha256`). - **Install location:** extracted into the app's private on-device Alpine rootfs by `LocalRuntimeInstaller`/`LocalOpenCodeBackend`, after SHA-256 verification. -- **Launch:** started as a local HTTP server bound to `127.0.0.1:4097` inside the Alpine PRoot +- **Launch:** started as a local HTTP server bound to `127.0.0.1:4098` inside the Alpine PRoot sandbox (`LocalOpenCodeBackend`); AndCode talks to it over `OpenCodeApiClient` like any OpenCode client would. For **remote OpenCode**, AndCode instead connects to an OpenCode server you already run on your own PC/Mac/Linux (`RemoteOpenCodeBackend`), given a URL/username/password you supply. diff --git a/docs/COMPLETION_CHECKLIST.md b/docs/COMPLETION_CHECKLIST.md index d4a9540a7..eedeb63a1 100644 --- a/docs/COMPLETION_CHECKLIST.md +++ b/docs/COMPLETION_CHECKLIST.md @@ -40,7 +40,7 @@ - [x] Alpine minirootfs導入 - [x] 公式OpenCode musl配布物のSHA-256検証 - [x] Git / Bash / curl / SSH / CA証明書 / ripgrep / libstdc++の自動導入 -- [x] `127.0.0.1:4097`限定起動 +- [x] `127.0.0.1:4098`限定起動 - [x] Foreground Serviceによる起動・停止 - [x] OpenCode 1.18.3 health・モデル・エージェント・推論の実動作 - [x] セットアップ・修復UI diff --git a/docs/LOCAL_RUNTIME.md b/docs/LOCAL_RUNTIME.md index e19646de7..32fc2e4b3 100644 --- a/docs/LOCAL_RUNTIME.md +++ b/docs/LOCAL_RUNTIME.md @@ -16,7 +16,7 @@ OpenCode本体はフォークしない。アプリ専用ストレージ内へLin - URL・サイズ・SHA-256を固定したランタイムマニフェスト - 一時領域への展開と、成功後だけ本番環境へ切り替えるインストール - Alpine内へのGit、Bash、curl、OpenSSH client、CA証明書、ripgrep、libstdc++導入 -- `127.0.0.1:4097`限定のOpenCodeサーバー起動 +- `127.0.0.1:4098`限定のOpenCodeサーバー起動 - Foreground Serviceによるセットアップ・起動・停止・稼働監視・自動復旧 - 未導入、導入中、起動中、停止中、稼働中、破損、未対応ABIの状態管理 - 作業先画面からのセットアップ、起動、停止、修復・再セットアップ @@ -88,7 +88,7 @@ opencode --versionを確認 ↓ 環境を本番ディレクトリへ切り替え ↓ -opencode serve --hostname 127.0.0.1 --port 4097 +opencode serve --hostname 127.0.0.1 --port 4098 ↓ /global/healthが成功したらReady ```