fix(desktop): retire the owned local Host on full quit - #3706
Conversation
Treat full Desktop quit as an explicit retirement intent for the Desktop-owned ephemeral Host. Share exact-host retirement across Desktop update and managed service adapters, while leaving service and remote Hosts alone.\n\nCancel quit and surface trusted Host facts when safe retirement fails.\n\nGenerated-by: Codex
Astro-Han
left a comment
There was a problem hiding this comment.
The quit ordering here is the right shape. Retiring the Host first, waiting for it to actually exit, and only then closing Client resources means there is no window in which Desktop has torn down its side while the Host is still writing: a power loss at any point leaves the State Root either fully intact from before the exit or untouched. Failing preparation returns to running, leaves every resource open, and shows the failure instead of half-quitting, which is the right direction to fail in. I checked that the ownership rule now refuses service and remote hosts explicitly rather than reporting them prepared, and that a second quit request during preparing or cleaning cannot re-enter.
One thing to fix before this merges.
[P2] A concurrent update swallows the quit's authorization to interrupt active work
retireOwnedLocalHost(mode) in apps/desktop/src/main/runtime-host-desktop-manager.ts:522-524 returns any in-flight retirement task without consulting mode, and the line above it returns a cached completed retirement the same way:
if (this.#localHostRetirement) return Promise.resolve(this.#localHostRetirement);
if (this.#localHostRetirementTask) return this.#localHostRetirementTask;So the mode of the first caller wins and later callers silently inherit it. Start a default update preparation, which asks for refuse_active_work and then waits on diagnostics, the candidate barrier, and the Host request. While that is outstanding, quit the app. Quit asks for interrupt_active_work, but that request is never sent — it joins the refuse-mode task. If the Host has active work, that task resolves { kind: 'active_tasks' }, and quit reads its own authorized retirement as refused and cancels the exit. The user authorized interrupting the work and stays in the app anyway, because of an unrelated background update intent. Retrying may or may not converge, depending on whether the update task has settled by then.
The existing coalescing test covers the case where the first task succeeds with no active work; it does not cover a first task that returns active_tasks while a second caller is asking to interrupt.
This also makes a second problem reachable. When quit issues its own request the active_tasks branch cannot fire, because interrupt_active_work maps to allowInterruptActiveTasks: true and packages/runtime-host/src/server/host-kernel.ts:640 only returns active_tasks for the negated case. On the coalesced path it fires, and the code there throws a plain Error rather than a DesktopLocalHostRetirementError, so the failure dialog loses the State Root, host epoch and PID that the rest of this change works to put in front of the user — exactly when they need them to decide what to do.
For the fix, the shared transaction needs to carry at least the strongest requested mode, or an interrupt caller arriving on a refuse task that resolved active_tasks should re-issue its own retirement once the first settles, with the quiescence and barrier state kept consistent. Whichever you pick, please wrap that error path as a retirement error too, and add the reverse-mode coalescing case to the test.
One observation that is not a finding and not yours: waitForHostExit has no timeout, so a Host that hangs during retirement parks the quit in preparing indefinitely, leaving the user to kill the process by hand. That is inherited unchanged from the previous prepareForUpdate and your failure dialog already documents the manual path, so I am noting it only so it is written down somewhere.
简体中文
这里的退出顺序是对的。先退役 Host、等它真正退出、之后才关闭 Client 资源——这意味着不存在「Desktop 这边已经拆掉、而 Host 还在写」的窗口:断电落在任何一点上,磁盘上的 State Root 要么是退出前的完整状态,要么根本没被动过。准备失败时回到 running、所有资源保持打开、把失败显示出来而不是退到一半,失败的方向也是对的。我核过所有权判定现在会明确拒绝 service 与 remote 而不是把它们报成已准备,也核过 preparing / cleaning 期间的第二次退出请求无法重入。
合并之前有一处需要修。
[P2] 并发的更新会吞掉退出流程「中断活跃工作」的授权
apps/desktop/src/main/runtime-host-desktop-manager.ts:522-524 的 retireOwnedLocalHost(mode) 在存在在途退役任务时直接返回它,完全不看 mode;上一行对已完成的缓存结果也是同样处理:
if (this.#localHostRetirement) return Promise.resolve(this.#localHostRetirement);
if (this.#localHostRetirementTask) return this.#localHostRetirementTask;于是第一个调用者的 mode 胜出,后来者静默继承它。先发起一次默认的更新准备,它请求 refuse_active_work,随后在 diagnostics、candidate barrier 和 Host 请求上等待;就在它未决期间,用户退出应用。退出流程请求的是 interrupt_active_work,但这个请求根本没有发出去——它并入了那条 refuse 模式的任务。如果 Host 此时有活跃工作,该任务解析为 { kind: 'active_tasks' },退出流程于是把自己那次已授权的退役读成被拒绝,并取消退出。用户明明已经授权中断工作,却因为一个毫不相干的后台更新意图而被留在应用里。重试能否收敛,取决于那条更新任务此时是否已经结束。
现有的合并测试覆盖的是「第一条任务在没有活跃工作时成功」;它没有覆盖「第一条返回 active_tasks、而第二个调用者正要求中断」这个反向组合。
这还让另一个问题变得可达。 当退出流程发出自己的请求时,active_tasks 分支是打不到的:interrupt_active_work 映射为 allowInterruptActiveTasks: true,而 packages/runtime-host/src/server/host-kernel.ts:640 只在取反的情况下返回 active_tasks。但在上述合并路径上它会触发,而那里的代码抛的是普通 Error 而非 DesktopLocalHostRetirementError,于是失败对话框丢掉了 State Root、host epoch 和 PID——而这些正是这个 PR 花力气要摆到用户面前的事实,也正是用户在此刻决定怎么办所需要的。
修复方向:共享的那个事务至少要携带「请求过的最强 mode」;或者当一个 interrupt 调用者并入了一条最终返回 active_tasks 的 refuse 任务时,在第一条结束后重新发起属于它自己的退役,并保持 quiescence 与 barrier 状态一致。无论选哪种,请同时把那条错误路径也包装成 retirement error,并把反向 mode 的合并用例补进测试。
另有一条观察,既不是 finding 也不该算在你头上:waitForHostExit 没有超时,因此一个在退役过程中挂死的 Host 会让退出无限停在 preparing,用户只能手工杀进程。这是从原先的 prepareForUpdate 原样继承下来的,而且你的失败对话框已经写明了手工处理路径——记在这里只是为了让它有个落处。
A concurrent update retirement may be refused because work is active, but that weak result must not absorb an authorized quit. Track the mode with the in-flight retirement and retry the exact Host with interrupt authority only when the weak request is refused. Treat an active-work response to an authorized retirement as a retirement failure so the existing diagnostic wrapper retains Host identity and PID details. Keep the bounded exit wait terminology independent of the initiating lifecycle operation. Generated-by: Codex
4890cb9 to
82ba73f
Compare
English@Astro-Han Thank you for the careful review. The P2 is valid and is fixed in The in-flight retirement now owns both its mode and result. Successful retirement still coalesces into one exact-epoch request. When an This intentionally does not mutate a shared transaction to the globally strongest mode. Doing so would let the strong quit authorization change the result of the already-running weak update request. The retry rule keeps the authorization and result attached to the caller that supplied them, while adding only one fact to the existing in-flight-task owner: An The regression coverage now includes:
On the timeout observation: the current implementation already has a 10-second deadline in Verification: Desktop manager tests 32/32; full Desktop compiled suite 1410/1410; Desktop main TypeScript build passed. 简体中文@Astro-Han 感谢细致审查。这个 P2 成立,已在 在途退役现在同时拥有 mode 和 result。成功退役仍然会合并为一次绑定精确 epoch 的请求。当 这里有意没有把共享事务改成“全局最强 mode”。那样会让强退出授权改变已经运行中的弱更新请求的结果。现在的重试规则让授权及其结果继续归属于提供该授权的调用者,同时只给既有的在途任务 owner 增加一个必要事实: 如果 Host 对 回归覆盖包括:
关于超时观察:当前 验证:Desktop manager 测试 32/32;Desktop 编译后全量测试 1410/1410;Desktop main TypeScript build 通过。 |
Astro-Han
left a comment
There was a problem hiding this comment.
Approving on 82ba73f3bb30f2de2670f22fdb1d8471cb245ab8, with both required checks (test, package) terminal green on that exact head.
The fix is right where it needed to be. Caching the in-flight retirement task without its mode meant a refuse attempt could satisfy a later authorized quit; storing { mode, result } and re-issuing when a refuse task returns active_tasks closes that. The throw for a refused authorized retirement is inside the try, so the catch still calls resume() before wrapping the failure — the Host is not left suspended on the error path, and the wrapped error carries hostId / hostEpoch / rootPath / pid, which is what an operator actually needs to act on.
One note for the record, not a blocker: the two earlier test failures on this head were unrelated e2e specs, and each rerun failed on a different spec. That points at flakiness in the e2e suite rather than at this change, and it is worth tracking separately.
简体中文
已在 82ba73f3bb30f2de2670f22fdb1d8471cb245ab8 上 approve,该 exact head 的两个必需检查(test、package)均为终态绿。
修复点是对的。此前缓存在途 retirement task 时没有带上 mode,导致一次 refuse 尝试可以被后续的 authorized quit 复用;改为存 { mode, result }、并在 refuse task 返回 active_tasks 时重新发起,堵住了这条路。针对「authorized retirement 被拒绝」抛出的错误位于 try 内部,因此 catch 仍会先调用 resume() 再包装失败——错误路径上 Host 不会被留在挂起状态,且包装后的错误带有 hostId / hostEpoch / rootPath / pid,正是运维需要的信息。
另记一笔,不构成阻塞:本 head 上先前两次 test 失败都是无关的 e2e 用例,且两次失败的用例还不相同。这指向 e2e 套件本身的不稳定,而非本次改动,值得单独跟进。
|
LGTM. Merging at the author's request — checks are green on 简体中文LGTM,按作者要求合并—— |
English
Summary
Fixes #3703
Treat a full Desktop application quit as an explicit retirement intent for the Desktop-owned ephemeral Runtime Host. Quit now waits for authenticated, exact-epoch Host retirement before closing Client resources; if retirement cannot be confirmed, quit is cancelled and Maka remains usable.
The retirement request is exposed as a shared Client contract instead of a Desktop-quit special case. Desktop Quit, Desktop Update, and managed service retirement now share the same epoch-bound adapter while the historical
host.upgrade.preparewire name remains isolated behind it. Service-mode and remote Hosts are explicitly outside Desktop ownership and are not retired.When safe retirement fails, the dialog reports the State Root, Host epoch, underlying cause, and the authenticated Host PID when available. Manual recovery copy uses a cross-platform “operating system process-management tool” concept rather than naming a platform-specific utility.
Verification
npm --workspace @maka/desktop run test:dist— 1410 passednpm --workspace @maka/desktop run build:main— passednpm --workspace @maka/runtime-host run buildand the shared retirement test — passednpm --workspace maka-agent run buildandnpm --workspace maka-agent run typecheck— passedgit diff --check— passed (Desktop is excluded from Biome formatting)npm --workspace @maka/desktop run build:workspace-depsremains blocked by pre-existing@maka/uisource/type-surface errors on current main (settledText,conversationKey,unlockAutoFollow, andtrailingAction). The affected Desktop main build and full compiled test suite pass independently.Review focus
window-all-closed → app.quit()mapping makes last-window close a full application quit, so it now retires the Desktop-owned ephemeral Host.AI use
Select exactly one:
Tool(s) and scope: Codex investigated the lifecycle boundary, implemented the shared retirement adapter and Desktop quit coordination, added user-facing failure diagnostics and regression tests, and ran the listed verification. The commit includes the required
Generated-by: Codextrailer.Checklist
Does this PR entail a change in behavior?
简体中文
摘要
修复 #3703
将 Desktop 应用完整退出建模为对 Desktop 所拥有的临时 Runtime Host 的显式退役意图。现在,退出会先等待经认证且绑定精确 Host epoch 的退役完成,再关闭 Client 资源;如果无法确认安全退役,退出会被取消,Maka 保持可用。
退役请求被提升为共享 Client 契约,而不是 Desktop Quit 的特例。Desktop Quit、Desktop Update 和托管 service 退役现在复用同一个绑定 epoch 的适配器,同时将历史遗留的
host.upgrade.preparewire 名称隔离在适配器内部。service 模式和远端 Host 明确不属于 Desktop 的所有权范围,因此不会被 Desktop 退役。安全退役失败时,弹窗会显示 State Root、Host epoch、底层原因,以及可获得时经认证的 Host PID。手动恢复文案使用跨平台的“操作系统进程管理工具”概念,不引用任何平台专有工具名称。
验证
npm --workspace @maka/desktop run test:dist— 1410 项通过npm --workspace @maka/desktop run build:main— 通过npm --workspace @maka/runtime-host run build与共享退役测试 — 通过npm --workspace maka-agent run build与npm --workspace maka-agent run typecheck— 通过git diff --check— 通过(Desktop 不在 Biome 格式化范围内)npm --workspace @maka/desktop run build:workspace-deps仍被当前 main 上既有的@maka/ui源码/类型表面错误阻塞(settledText、conversationKey、unlockAutoFollow和trailingAction)。本次影响范围内的 Desktop main build 与完整编译后测试套件均可独立通过。审查重点
window-all-closed → app.quit()映射,因此关闭最后一个窗口属于完整应用退出,并会退役 Desktop 所拥有的临时 Host。AI 使用
仅选择一项:
工具与范围:Codex 调查了生命周期边界,实现了共享退役适配器与 Desktop 退出协调,补充了用户可见的失败诊断和回归测试,并执行了上述验证。提交已包含所需的
Generated-by: Codextrailer。检查清单
该 PR 是否包含行为变更?