Uh oh!
There was an error while loading. Please reload this page.
feat(runtime-host): add managed Host retirement - #3557
Conversation
3c62651 to
d7fb7bdCompareManaged updates need a local operator path that fences admission before the service releases its State Root. Reuse the existing exact-Epoch Host prepare contract, then let the CLI service manager stop the unit and verify writer release. The new retire action remains outside ordinary remote Client authority and returns bounded active-work or completed-retirement facts for later update orchestration. Generated-by: Codex
Bind retirement to the managed service PID, stop every non-stable service state, and serialize standalone mutations with setup lifecycle operations. Keep the operator result contract explicit without projecting diagnostic snapshots as authoritative blockers. Generated-by: Codex
Give intentional Host retirement a process outcome that the service manager treats as successful without restarting. Require every reported service PID to pass exact-Host preparation, and make active-work refusal unsuccessful to shell and JSON callers. Generated-by: Codex
Hold the exact State Root writer while stopping a transitional managed service that has not published a PID. This prevents a systemd restart from admitting durable work between the status snapshot and stop, while failing closed if another Host wins the writer first. Generated-by: Codex
d7fb7bd to
0768ef9Compare
Astro-Han
left a comment
There was a problem hiding this comment.
Independent review of 0768ef9bbe526ce09e0d949de1c346dfb65f24d6, from two lines that did not share a starting point or read each other's findings.
No P0–P2. One [P3]. Exact-head checks: test is completed / success on that SHA. audit and package did not run on this head — recorded as not-run, not as green.
The retirement chain holds
Identity binding is triple-checked on the pid-known path: service status pid, then queryHostDiagnostics pid, then the pid returned by host.upgrade.prepare, with expectedHostEpoch anchoring the operation to one exact Host. Blocking is decided inside the Host by #hasUpgradeBlockingActivity() rather than projected from the CLI's diagnostics snapshot, and active_tasks is returned to the caller rather than acted on. After the drain gate is set, both the handshake admission read and begin-operation refuse new work, so a task starting just after the check cannot slip past the fence.
--allow-interrupt-active-tasks is a per-call boolean. It is not persisted to service config, does not change later defaults, and the operation is absent from REMOTE_OWNER_OPERATION_GRANTS — only local-owner IPC can invoke it. Its scope is broad (all blocking work on that exact Host) but explicit, which matches forced-retirement semantics.
Restart suppression is closed rather than assumed: shutdownReason='retirement' drives both exit paths to code 3, and the unit sets SuccessExitStatus=3 with RestartPreventExitStatus=3 against Restart=always. Note that a manual systemctl start remains possible; that is an operator action, not a service-manager auto-restart, and nothing here claims otherwise.
[P3] The pid-known path does not re-verify identity across stop
packages/cli/src/runtime-host-service-manager.ts acquires rootFence only in the starting/pid-less branch. The pid-known path calls backend.stop() with no fence held, and afterwards checks only that the service reached stopped with pid === null, then that the root was released.
If the old Host crashes during drain with a code other than 3, Restart=always can bring up a successor that takes the State Root writer and admits durable work before our stop lands. That stop then terminates the successor, the stopped-state assertion passes, the root is genuinely released — and the call still reports {kind: 'retired', hostEpoch, pid} carrying the original identity. A generation change happened inside the window that was supposed to end in retirement, the successor's writes remain, and the success result names a Host that is not the one that was stopped.
No data is lost — writer exclusivity is never violated — and the window requires a crash plus a restart to land between prepare and stop, so this is a narrow timing path rather than anything an attacker steers. Hence P3.
The minimal fix is to hold the root fence across stop on the pid-known path as well, or to re-read identity evidence after stop (restart counter, or reconnect and compare epoch) before reporting retired.
Boundaries
The service-management authority is split cleanly — Host owns admission and drain, the CLI owns managed PID, stop, and writer observation, systemd owns restart policy — and package staging, replacement startup, and update policy stay outside Runtime Host, as the description states. No duplicated retirement authority was found across the 14 files.
Limitations
Both reviewers were on Linux and could not exercise real systemd unit behaviour; the SuccessExitStatus / RestartPreventExitStatus link rests on source reading plus unit-rendering assertions, not on observed systemd action. Local runs: service-manager 21/21 and setup green; host-kernel 57/61, where the four failures reproduce identically on pristine main (missing Electron libatk in this environment) and are not attributed to this PR. The author's own full runtime/agent/desktop suites and the disposable systemd end-to-end run were not independently reproduced.
AI-assisted review, two independent lines. Under CONTRIBUTING.md §Review this does not replace the required independent human review.
Astro-Han
left a comment
There was a problem hiding this comment.
Two additions from a third reviewer, after the approval above. Neither changes the P3 severity; both make it more actionable.
A better fix direction than the one I suggested. I proposed either holding the fence across stopor re-reading identity after stop. The second option is weak: by the time you re-read, the successor has already been stopped and its writes cannot be undone, so you learn about the generation change without being able to prevent it. The right shape is to acquire the root fence afterprepare has released the old Host and hold it throughbackend.stop() until the service is stably stopped — failing closed if a successor wins the lock first, rather than stopping it as though it were the original Host. The starting/pid-less branch already has exactly this shape; the pid-known branch should converge on the same authority.
There is also a narrow semantic consequence worth noting: work admitted by the successor inside that window is invisible to the old Host's active-work check, so allowInterruptActiveTasks=false can be satisfied while durable work is nonetheless interrupted.
The retired wording overstates what the unit does.runtime-host-systemd-service.ts:218 sets RestartPreventExitStatus=3, which suppresses Restart=always for that exit only. The unit remains installed and enabled with WantedBy=default.target at :227. So besides a manual systemctl start, a future user-manager or default-target activation will also start it. The current human-facing "service is retired" reads as durable disablement.
Suggested wording: stopped for replacement; immediate restart suppressed; unit remains installed and enabled, and may be started explicitly or by a future target activation.
For the record on what verifyRuntimeHostRootReleased() proves: tryAcquireInteractiveRootOwner() takes an OS-level non-blocking exclusive lock on the exact root's owner.lock and revalidates the lock artifact against root identity, so a successful acquisition is real observation — at that instant, the old Host holds no writer and neither does anyone else. It is a snapshot of lock vacancy, not evidence of an identity-contiguous handoff, and it stops proving anything once owner.close() runs.
English
Summary
Adds the local-operator retirement boundary required before a managed Runtime Host service can be replaced. The CLI now binds the exact live Host to the managed service process, reports when active work blocks retirement unless interruption was explicitly authorized, stops every non-stable service state, prevents intentional retirement from being restarted by the service manager, and verifies that the State Root writer was released. Standalone service mutations are serialized with setup and deployment lifecycle operations.
This reuses the existing exact-Epoch Host prepare contract. Package staging, version switching, replacement startup, and update policy remain outside Runtime Host authority.
Refs #3231
Refs #3228
Verification
npm --workspace @maka/runtime-host test— 1,086 tests passednpm --workspace maka-agent test— 393 tests passednpm run lintnpm run format:checknpm run typecheckAI use
Tool(s) and scope: OpenAI Codex contributed substantively to the implementation and tests under the contributor's direction. The human contributor owns review and submission.
Checklist
Does this PR entail a change in behavior?
简体中文
概要
增加 managed Runtime Host service 在替换前所需的 local-operator 退场边界。CLI 现在会将精确的 live Host 与 managed service 进程绑定;若未明确授权中断,则在存在 active work 时阻止退场;停止所有非稳定状态的 service,阻止 service manager 重启主动退场的进程,并验证 State Root writer 已释放。独立执行的 service mutation 也会与 setup 和 deployment lifecycle 操作串行化。
该实现复用现有的 exact-Epoch Host prepare contract。Package staging、版本切换、replacement 启动和更新策略仍不属于 Runtime Host authority。
关联 #3231
关联 #3228
验证
npm --workspace @maka/runtime-host test— 1,086 项测试通过npm --workspace maka-agent test— 393 项测试通过npm run lintnpm run format:checknpm run typecheckAI 使用
工具与范围:OpenAI Codex 在贡献者指导下实质参与实现与测试。人工贡献者负责审查与提交。
检查清单
本 PR 是否改变行为?