Skip to content

fix(desktop): recover managed Runtime Host startup - #4420

Merged
M4n5ter merged 2 commits into
mainfrom
fix/runtime-host-startup-recovery
Sep 1, 2026
Merged

fix(desktop): recover managed Runtime Host startup#4420
M4n5ter merged 2 commits into
mainfrom
fix/runtime-host-startup-recovery

Conversation

@M4n5ter

@M4n5ter M4n5ter commented Sep 1, 2026

Copy link
Copy Markdown
Member
English

Summary

Recover an existing Desktop workspace when its managed Local Runtime Host can no longer start after a package or durable-schema transition.

  • Reuse the canonical managed-package update transaction; there is no second repair path.
  • Migrate the retired task.ledger.query grant to session.todo.query, allowing current Hosts to open credentials issued before the SessionTodo cutover.
  • First attempt a non-destructive repair. If compatibility evidence requires a manual update, admit it only after the user chooses Repair Runtime Host, fenced to the exact package version, managed root, and deployment generation.
  • Keep manual-update consent separate from active-work interruption. If recovery encounters active work, ask again before granting interruption authority.
  • Preserve the State Root, deployment and Host identities, credentials, settings, and workspace data.
  • Keep the failed initial manager from terminating Electron while the recovery state machine owns the failure.

Root cause

The observed workspace was caught between two versions: the older Host rejected newer managed-deployment state, while the current Host rejected a persisted pre-SessionTodo grant. The update rolled back and Desktop could only exit. This change makes the durable grant transition explicit and fail-closed, then lets the existing transactional updater repair the exact managed deployment under explicit authority.

Verification

  • CLI and all four Desktop TypeScript configurations passed (--incremental false for Desktop).
  • 59 focused Runtime Host, CLI lifecycle/update, and Desktop recovery tests passed.
  • Biome lint/format checks and git diff --check passed.
  • Real npm run dev:peer -- --remote-debugging-port=9222 recovery: the managed Host repaired, the original workspace and task history opened, and the deployment ID plus both credential records remained unchanged.
  • Parallel deep-code-review and simplify-audit loops converged with no remaining Blocking/Important findings.

Native fallback

Native Runtime Host recovery dialog

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: OpenAI Codex investigated the startup/update failure, implemented the recovery and compatibility migration, tested the real recovery path, and performed parallel correctness and simplification reviews.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No
中文

摘要

当 Desktop 的既有工作区因托管 Local Runtime Host 的包版本或持久状态演进而无法启动时,现在可以原地恢复。

  • 复用唯一的托管包更新事务,不另造第二条修复路径。
  • 将旧的 task.ledger.query grant 迁移为 session.todo.query,让当前 Host 能读取 SessionTodo 切换前签发的凭证。
  • 首先进行不破坏现有工作的自动修复。若兼容性证据要求手动更新,只有用户点击“修复 Runtime Host”后才放行,并绑定精确包版本、managed root 和 deployment generation。
  • 手动更新许可与中断活动任务的许可保持独立;若恢复时发现活动工作,会再次明确询问后才授予中断权限。
  • 保留 State Root、deployment 与 Host 身份、凭证、设置和工作区数据。
  • 初次启动失败由恢复状态机接管时,不让旧的 fatal callback 提前退出 Electron。

根因

真实工作区卡在两个版本之间:旧 Host 拒绝新版托管 deployment 状态,而当前 Host 又拒绝持久化的 SessionTodo 切换前 grant,导致更新回滚,Desktop 最终只能退出。本修复显式且 fail-closed 地迁移持久 grant,并让既有事务化 updater 在明确 authority 下修复精确的托管 deployment。

验证

  • CLI 与 Desktop 四套 TypeScript 配置均通过(Desktop 使用 --incremental false)。
  • 59 个聚焦于 Runtime Host、CLI lifecycle/update 与 Desktop 恢复的测试通过。
  • Biome lint/format 与 git diff --check 通过。
  • 真实运行 npm run dev:peer -- --remote-debugging-port=9222:托管 Host 修复完成,原工作区和历史任务正常打开,deployment ID 与两条 credential 记录均保持不变。
  • 并行 deep-code-review 与 simplify-audit 循环已收敛,无剩余 Blocking/Important finding。

原生回退窗口

原生 Runtime Host 恢复窗口

AI 使用

OpenAI Codex 协助定位启动/更新问题、实现恢复与兼容迁移、验证真实恢复路径,并完成并行 correctness 与 simplify 审查。

@M4n5ter
M4n5ter force-pushed the fix/runtime-host-startup-recovery branch from 5857cc4 to 6bc4d8e Compare September 1, 2026 04:37

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the root cause and the grant migration independently, and the migration is authority-preserving: task.ledger.query was in REMOTE_OWNER_OPERATION_GRANTS at v0.1.11, and session.todo.query is in the same set on main — read for read, remote-allowed for remote-allowed. Putting it in the migration ladder rather than RETIRED_OPERATION_GRANTS is the right call, since this capability has a successor. Reusing the transactional updater as the single recovery authority rather than adding a second repair path is also the right shape.

Approving. The two observations below are non-blocking and can be addressed here or in a follow-up.

1. The onFatalError guard is broader than the failure the recovery machine owns (apps/desktop/src/main/runtime-host-boot.ts:1081). runtimeHostManager stays unassigned until the whole recovery settles, so the guard suppresses every local fatal during startup. #publishState (apps/desktop/src/main/runtime-host-desktop-manager.ts:1161) calls onFatalError when onTargetStateChanged throws, and that path never rejects the start promise — so the error is dropped entirely: no quit, no dialog, and the recovery machine never sees it either. Narrowing the guard to canRepairManagedRuntimeHostStartup(error) would state the intent exactly.

2. The repair dialog does not disclose that it bypasses the compatibility gate. --allow-manual-update lets compatibility_mismatch and the two *_compatibility_unknown reasons through the manual_action gate (packages/cli/src/runtime-host-update-command.ts:865). Gating it behind the explicit prompt, and requiring an exact target plus deployment id, is a good boundary. But the copy only promises what is preserved; a user clicking "Repair Runtime Host" is not told the repair may install a Host the compatibility check rejected. One sentence in detail would make the consent match the action.

Minor: assertExpectedSupervisorOwner now runs on a path that previously skipped it, so service retire/update without --allow-interrupt-active-tasks emits active_tasks where it previously emitted transition_failed. No success path regresses — that branch always threw before — but it is an observable change in the CLI's JSON error code if anything consumes it.


AI-assisted review disclosure: Claude Code performed the diff analysis and traced the grant, lifecycle, and startup-recovery paths. I reviewed its findings and verified the load-bearing claims myself — the v0.1.11 and main remote-owner grant sets, the decode failure in validateStoredGrants, and the two paths named above — and the approval decision is my own.

@M4n5ter

M4n5ter commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

@Astro-Han

English

Addressed the two actionable observations in b3934dc:

  • Narrowed the startup-time fatal guard to suppress only repairable managed Local Host startup failures. Unrelated startup failures and observer failures retain their normal fatal handling.
  • Updated the native recovery dialog to disclose that repair may replace the installed Host with the version selected for this Desktop when automatic update compatibility cannot be confirmed. The existing dialog test now protects that consent text.

I kept the active_tasks result unchanged. That semantic change is intentional: recovery must distinguish active work from a generic transition failure so Desktop can request separate interruption authority. Preserving the previous transition_failed code would remove that actionable state. The expected-supervisor-owner check remains the identity fence.

Verified with the Desktop main build, main TypeScript check, and the focused native-dialog/startup-recovery tests (7 passed).

中文

已在 b3934dc 中处理两项需要修改的观察:

  • 将启动期 fatal guard 收窄为仅抑制可修复的托管 Local Host 启动失败;无关的启动错误和 observer 错误继续走原有 fatal 处理。
  • 更新原生恢复窗口,明确说明无法确认自动更新兼容性时,修复可能使用此 Desktop 选择的版本替换当前 Host;现有窗口测试也已覆盖这项 consent 文案。

active_tasks 结果保持不变。这是有意的语义变化:恢复流程必须将“仍有活动工作”与笼统的 transition failure 区分开,Desktop 才能单独请求中断授权。保留旧的 transition_failed 会丢失这个可操作状态。expected-supervisor-owner 检查继续作为身份 fence。

Desktop main 构建、main TypeScript 检查以及 native dialog/startup recovery 定向测试均已通过(7 个测试)。

@M4n5ter
M4n5ter merged commit 8bc4846 into main Sep 1, 2026
18 checks passed
@M4n5ter
M4n5ter deleted the fix/runtime-host-startup-recovery branch September 1, 2026 05:42
Astro-Han added a commit that referenced this pull request Sep 1, 2026
…rity it derives

A credential's grant list served two masters. As a record it should hold what
some earlier release wrote; as an authority it may only name operations this
build defines and this principal's policy allows. Decoding reconciled them in
place, so every vocabulary change had to be patched into the same array —
first three hand-written constants, then a fourth for the SessionTodo cutover
that stranded workspaces holding a credential issued before it (#4420).

The record is now `StoredAccessCredential.grants`, kept as the file states it,
and `effectiveOperationGrants` derives the authority on every decode without
writing back. A grant this build cannot serve is absent from the authority and
present in the record, so an unrelated later mutation no longer erases it —
neither a newer build's key seen by an older one, nor a key whose migration
entry was forgotten. `PERSISTED_GRANT_MIGRATIONS` is the only thing that
rewrites the record, and a replacement naming no successor fails to compile.

Deriving per principal also puts a Client Capability provider under its own
policy rather than the remote owner's, which decoding never applied, and
retires the Session Guest special case: its record was never authoritative, so
it is simply one more derivation rule.

The published JSON keeps `operationGrants` as its key. An explicit encoder
states the on-disk shape once, so a field added to the runtime type cannot
reach the file by accident.

Refs #4420

Generated-by: Claude Code (claude-opus-5)
Astro-Han added a commit that referenced this pull request Sep 1, 2026
The forward-roll job existed and already ran, but nothing woke it when the
code that decodes durable state changed. The trigger now selects it from the
decoders and, decisively, from the operation vocabulary they decode against:
the SessionTodo cutover that caused #4420 changed `protocol/operations.ts` and
no decoder, so a decoder-only trigger stays green on the exact change shape
this guard exists to catch. The planner test pins that path.

It runs on the heavy lane rather than the CLI packaging lane. Packaging is
nine minutes that prove nothing this check needs; the baseline is instead the
published predecessor, downloaded and integrity-checked against the registry's
own digest, and read by the workspace already built on that lane. That costs
about a minute of wall clock on roughly one commit in ten, against a full
cross-platform matrix of runner time — and runner time is the scarcer resource
here, which is why this lane is one job of serial steps to begin with.

Generated-by: Claude Code (claude-opus-5)
Astro-Han added a commit that referenced this pull request Sep 1, 2026
…rity it derives (#4427)

* fix(runtime-host): separate the persisted grant record from the authority it derives

A credential's grant list served two masters. As a record it should hold what
some earlier release wrote; as an authority it may only name operations this
build defines and this principal's policy allows. Decoding reconciled them in
place, so every vocabulary change had to be patched into the same array —
first three hand-written constants, then a fourth for the SessionTodo cutover
that stranded workspaces holding a credential issued before it (#4420).

The record is now `StoredAccessCredential.grants`, kept as the file states it,
and `effectiveOperationGrants` derives the authority on every decode without
writing back. A grant this build cannot serve is absent from the authority and
present in the record, so an unrelated later mutation no longer erases it —
neither a newer build's key seen by an older one, nor a key whose migration
entry was forgotten. `PERSISTED_GRANT_MIGRATIONS` is the only thing that
rewrites the record, and a replacement naming no successor fails to compile.

Deriving per principal also puts a Client Capability provider under its own
policy rather than the remote owner's, which decoding never applied, and
retires the Session Guest special case: its record was never authoritative, so
it is simply one more derivation rule.

The published JSON keeps `operationGrants` as its key. An explicit encoder
states the on-disk shape once, so a field added to the runtime type cannot
reach the file by accident.

Refs #4420

Generated-by: Claude Code (claude-opus-5)

* test(release): qualify the access record beside the State Root

The forward roll restored and compared the State Root alone, but the record
that decides whether a Host starts at all is the access credential file, and
it lives in the account-local control namespace rather than inside the Root.
The harness was structurally blind to it: no seeded credential, no golden
capture of that directory, so a release could strand every existing workspace
and still qualify.

The fixture now asks the released build to issue a credential with everything
it is able to grant, rather than naming operations here — a fixture that
hard-codes today's keys stops covering the next rename the moment it lands.
Golden capture and restore span both durable locations.

Inspection also asserts that the reading build can account for every stored
grant. That question is asked of whichever build is reading rather than
compared between them, so a rename shipping without its migration entry fails
on the candidate while the released build, which predates the check, skips it.

Generated-by: Claude Code (claude-opus-5)

* ci: run the released forward roll when durable state decoders change

The forward-roll job existed and already ran, but nothing woke it when the
code that decodes durable state changed. The trigger now selects it from the
decoders and, decisively, from the operation vocabulary they decode against:
the SessionTodo cutover that caused #4420 changed `protocol/operations.ts` and
no decoder, so a decoder-only trigger stays green on the exact change shape
this guard exists to catch. The planner test pins that path.

It runs on the heavy lane rather than the CLI packaging lane. Packaging is
nine minutes that prove nothing this check needs; the baseline is instead the
published predecessor, downloaded and integrity-checked against the registry's
own digest, and read by the workspace already built on that lane. That costs
about a minute of wall clock on roughly one commit in ten, against a full
cross-platform matrix of runner time — and runner time is the scarcer resource
here, which is why this lane is one job of serial steps to begin with.

Generated-by: Claude Code (claude-opus-5)
abhinav-phi pushed a commit to abhinav-phi/maka that referenced this pull request Sep 1, 2026
* fix(desktop): recover managed Runtime Host startup

Generated-by: OpenAI Codex

* fix(desktop): clarify Runtime Host recovery consent

Generated-by: OpenAI Codex
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/L Under 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants