Skip to content

fix(runtime): keep local OpenCode auto-start reliable - #11

Merged
doublepi123 merged 4 commits into
mainfrom
fix/opencode-autostart
Oct 17, 2026
Merged

doublepi123 merged 4 commits into
mainfrom
fix/opencode-autostart

Conversation

@doublepi123

Copy link
Copy Markdown
Owner

Fixes "the local OpenCode sometimes does not auto-start when opening the app" (cold start, after a long background stay, right after updating OpenCode).

Idle-stop race

  • The watchdog's idle stop set its in-progress flag only inside the launched coroutine, so a foreground return in between saw Ready and skipped the restore. A start that queued behind a running idle stop was then torn down by its stopSelf().
  • IdleStopGate (generation/token) orders idle-stop decisions against incoming commands. The in-progress flag is now set synchronously.
  • The stop re-checks idleness under the manager lock (stopIfStillWanted), ignoring its own lease tag (idleStopStillApplies).
  • finishIdleStop runs on the main thread, and a superseded stop keeps the service up. The idle timer resets on decline or supersede.

Interrupted install/update

  • A Broken status left by an interrupted activation (environment.rollback) or in-app update (update-transaction.json / rollback-transaction.json) is now restored on foreground return. Previously it never reached startLocked(), where that recovery lives.

Port

  • This fork uses 4098, and existing installs migrate from 4097.
  • An open port is ours when our own opencode serve proot is alive. Otherwise an authenticated global/health probe decides.
  • A server that is not ours is reported as "Port N is in use by another process" (localized) instead of Ready. It is never killed.

UI

  • When the user's own Stop is what keeps auto-start off, the chat shows a "Stopped manually" banner with a Start action (8 locales).

Tests

  • IdleStopGateTest, IdleStopDecisionTest (incl. a real manager stopIfStillWanted), IdleStopSequenceTest, LocalRuntimePortTest, RuntimeAutoStartPolicyTest, LocalRuntimeProcessLauncherTest.

Follow-ups (not in this PR)

  • Match --port too when deciding a surviving opencode serve owns the port.
  • Restore autoRestartEnabled when the superseding command did not set it.
  • Expose localRuntimeStoppedByUser as a StateFlow, so the banner updates even when the status itself does not change.
  • Use an atomic move for the metadata port migration.
  • Check ownership on the cold-start ensureRunning / update UpToDate paths too.

Note: written without a local JDK; CI is the first compile.

Pre-PR review

Reviewed in three rounds by repo-reviewer:

  1. REQUEST_CHANGES at ac7716c. Blockers: wrong LocalRuntimeStatus import; the idle stop counting its own lease as active work (so it never stopped); a hardcoded English port-in-use reason; ktlint wrapping in the new tests.
  2. APPROVE at 4d9e49f, after those were fixed.
  3. APPROVE at 2d0b3da (HEAD), which applies three of round 2's suggestions. Its report is below.

判定: APPROVE

ブロッカー

なし

提案(非ブロッキング)

  • LocalRuntimeProcessLauncher.kt:290-301 — ポート番号までは照合していません。 findOpenCodeServerPids は opencode serve かどうかだけを見ています。そのため、次の稀なケースでは他人のサーバーを自分のものと判定します。

    • 移行前の 4097 で動いていた自分の opencode serve が生き残っている。
    • その状態で、4098 は別のアプリが使っている。
    • startInstalled は 4098 の他人のサーバーを自分のものとみなし、HTTP probe を飛ばして Ready にします。

    ownsPort(port) から port を渡し、--port の直後の値が port と一致するかまで確かめると確実です(LocalRuntimeProcessLauncher.kt:66-67 の引数順と同じです)。これも後続課題に入れて構いません。

  • 見送った 4 件(auto-restart を元に戻す件、StateFlow 化、ATOMIC_MOVE、コールドスタート時の ownership 判定)を PR 本文に書く方針で問題ありません。

  • 未コンパイルのままなので、マージ前に CI の detekt spotlessCheck とユニットテストが通ることを確認してください。

チェック済み項目

  • 1. findOpenCodeServerPids:
    • findManagedRuntimeRootPids の結果を絞り込み、cmdline を \u0000 で分割して /usr/local/bin/opencode の直後が serve のものだけを残しています。
    • 起動時の proot の argv(LocalRuntimeProcessLauncher.kt:57-68、prefix の後に -w /workspace /usr/local/bin/opencode serve --hostname … --port …)とこの判定が一致することを確認しました。
    • ターミナルからユーザーが起動した opencode serve は proot の子プロセス側になり、親の cmdline には proot と runtimeDirectory の両方は現れません。そのため root の判定には引っかからず、誤って自分のものと判定されることもありません。
    • 既定の lambda は前に宣言された runtimeDirectory を参照しているので、正しく書けています(LocalRuntimeManager.kt:59)。
    • cmdline が読めないときは getOrDefault(emptyList()) で除外されます。
  • テスト: 新しいテストは本番の関数に偽の procRoot を渡しています。opencode serve、codex app-server、opencode run の 3 つから [100] だけが返るので、本番の経路を検証できています。既存の findManagedRuntimeRootPids のテストへの影響はありません。
  • 2. idleTracker.reset(): 割り込まれた経路でも idleTrackerLock の中で呼ばれており、断ったときの経路と揃いました(LocalRuntimeService.kt:639-641)。割り込んだのが Stop の場合でも、startWatchdog は cancel される scope 上で何もしないので問題ありません。
  • 3. KDoc: hasInterruptedActivation の説明が、environment.rollback と journal 2 種の両方を扱う実装と一致しました(LocalRuntimeInstaller.kt:243-251)。
  • 形式: 新しく追加した行はいずれも 140 字以下で、トップレベルの private const val OPEN_CODE_SERVER_BINARY は detekt の命名規則を満たしています。
  • 確認できなかったこと: shell が拒否されたため、git diff、コンパイル、テスト実行はできませんでした。ファイルを直接読んで確認しています。

Three ways the local server could stay down when the app came back:

- Idle-stop race: the watchdog set its in-progress flag only inside the
  launched coroutine, so a foreground return in between saw Ready and
  skipped the restore; and a start that queued behind a running idle
  stop was torn down by its stopSelf(). IdleStopGate orders idle-stop
  decisions against incoming commands, the in-progress flag is set
  synchronously, the stop re-checks idleness under the manager lock, and
  a superseded stop keeps the service up.
- Interrupted update: a Broken status left by an interrupted activation
  never reached startLocked(), where that recovery lives; the foreground
  gate now restores it when a rollback copy is present.
- Port: this fork moves to 4098 (existing installs migrate from 4097),
  and an open port held by a server that does not accept our credentials
  is reported as "Port N is in use by another process" instead of Ready.

The chat also shows a "Stopped manually" banner with a Start action when
the user's own Stop is what keeps auto-start off.
…onflicts

- the idle stop holds its own lease tag and the still-idle check ignores it;
  before, its own lease always read as active work, so it never stopped and
  retried every watchdog tick (idleStopStillApplies, tracker reset on decline)
- finishIdleStop runs on the main thread so it cannot race onStartCommand
- an open port is ours when our own proot is alive; only otherwise probe
- interrupted in-app updates (update/rollback journals) also allow restore
- port-in-use reason goes through LocalRuntimeMessages, in all locales
- fix LocalRuntimeStatus import, ktlint wrapping in the new tests
Codex's app-server and terminal sessions also run proot from the runtime
directory, so any of them made a foreign server on the port look like
ours. Match /usr/local/bin/opencode serve in the cmdline instead. Also
reset the idle timer when a newer command supersedes an idle stop, and
correct hasInterruptedActivation's KDoc.
@doublepi123
doublepi123 merged commit e6b3e50 into main Oct 17, 2026
6 checks passed
@doublepi123
doublepi123 deleted the fix/opencode-autostart branch October 17, 2026 08:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant