fix(desktop): recover managed Runtime Host startup - #4420
Conversation
Generated-by: OpenAI Codex
5857cc4 to
6bc4d8e
Compare
Astro-Han
left a comment
There was a problem hiding this comment.
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.
Generated-by: OpenAI Codex
EnglishAddressed the two actionable observations in b3934dc:
I kept the Verified with the Desktop main build, main TypeScript check, and the focused native-dialog/startup-recovery tests (7 passed). 中文已在 b3934dc 中处理两项需要修改的观察:
Desktop main 构建、main TypeScript 检查以及 native dialog/startup recovery 定向测试均已通过(7 个测试)。 |
…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)
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)
…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)
* fix(desktop): recover managed Runtime Host startup Generated-by: OpenAI Codex * fix(desktop): clarify Runtime Host recovery consent Generated-by: OpenAI Codex
English
Summary
Recover an existing Desktop workspace when its managed Local Runtime Host can no longer start after a package or durable-schema transition.
task.ledger.querygrant tosession.todo.query, allowing current Hosts to open credentials issued before the SessionTodo cutover.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
--incremental falsefor Desktop).git diff --checkpassed.npm run dev:peer -- --remote-debugging-port=9222recovery: the managed Host repaired, the original workspace and task history opened, and the deployment ID plus both credential records remained unchanged.Native fallback
AI use
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
Does this PR entail a change in behavior?
中文
摘要
当 Desktop 的既有工作区因托管 Local Runtime Host 的包版本或持久状态演进而无法启动时,现在可以原地恢复。
task.ledger.querygrant 迁移为session.todo.query,让当前 Host 能读取 SessionTodo 切换前签发的凭证。根因
真实工作区卡在两个版本之间:旧 Host 拒绝新版托管 deployment 状态,而当前 Host 又拒绝持久化的 SessionTodo 切换前 grant,导致更新回滚,Desktop 最终只能退出。本修复显式且 fail-closed 地迁移持久 grant,并让既有事务化 updater 在明确 authority 下修复精确的托管 deployment。
验证
--incremental false)。git diff --check通过。npm run dev:peer -- --remote-debugging-port=9222:托管 Host 修复完成,原工作区和历史任务正常打开,deployment ID 与两条 credential 记录均保持不变。原生回退窗口
AI 使用
OpenAI Codex 协助定位启动/更新问题、实现恢复与兼容迁移、验证真实恢复路径,并完成并行 correctness 与 simplify 审查。