Repository navigation
feat(managed-agent): prove Shell process-group stops with a worker ledger (M5c) - #13352
Conversation
…dger (M5c) M5a settled a cancel on the worker's word: a Shell member that ignored SIGTERM survived its leader, and a worker that crashed left the Shell process groups it started unnamed to the child's snapshot-only registry. Each Runtime worker now keeps an incarnated ledger of its Shell process groups, rewritten atomically before any settled step. A settled cancel waits for the whole group to die before the call journals; the host sweeps the ledger of a dead worker, and every new child sweeps older ledgers, neither one trusted. Identity is judged from the live process table with a leader-dated, one-sided start-time proof: a young live leader or a live pid the table cannot name is never signalled, and a group nothing can prove is a quarantine that blocks new Managed sessions until a reaper proves it. Registers and enables nothing; Windows stays on its documented liveness-only shape pending real-machine verification before M6. Refs #12380
E2E / verification report (M5c physical stop)The engine is inert by design here (registration is M6), so "E2E" means the real-process harness: a paired Bridge spawning a real Managed Baseline discrimination (pre-implementation, M5a at On this branch.
Mutation-witness evidence (replayed on this branch's base). Three witnesses are flip-pinned, i.e. their assertions fail when the mechanism is removed:
Witness-selection finding to know while reading F. On POSIX the worker's Shells run on a PTY, so a killed worker takes its foreground Shell group down via the kernel's hangup — verified on baseline (~100 ms, no M5c code). Process death alone therefore does not discriminate M5c; the F witnesses assert the ledger lifecycle (written during the Shell run, names the group, gone after the sweep). Where the PTY model does not reach (TERM-ignoring members with a fast-exiting leader at cancel, Windows Not validated here. Windows real-machine behavior (unit doubles only; required before M6 per the design); the |
Real-environment verification — M5c physical stop @
|
| Scenario | base 2c591ecc08 |
head 6cb680dca3 |
|---|---|---|
| C2 · SIGKILL the Runtime worker mid-call | member survives, reparented to init, still in the Shell's group | member SIGKILLed 26–27 ms later (Linux 51 ms) by the host's witnessed sweep; ledger deleted |
| C3 · SIGKILL the Managed child | worker gone in 0.26 s (Linux 0.56 s); member survives | worker lingers 10.1–10.4 s, then SIGKILLs the member and deletes the ledger |
| C4 · SIGKILL child and worker together | member survives the next POST /session and a tool turn in it |
member killed 0.66–0.74 s (Linux 2.3 s) after the next POST /session, by the new child's startup sweep |
| C5 · undatable group (leader dead; only a member born 8 s after the record) | sessions admitted; member untracked | every POST /session returns 409 until the member exits (30.5–31.7 s); admitted again 0.7–0.9 s later |
C6 · 0-byte or truncated *.json in the ledger dir |
n/a | 409 at 2 s, 10 s and 30 s; never lifts on its own; admitted 1.6–3.7 s after the file is deleted by hand |
| C1 · cancel; member ignores TERM and HUP | cancelled in 0.16–0.26 s; member leaks past session delete |
turn_error managed_runtime_outcome_unknown at 10.2–10.5 s; member SIGKILLed at 10.1–10.4 s; session blocked |
| C1b · cancel; member exits 3 s after TERM | cancelled at 0.11–0.16 s while the member still runs |
cancelled at 3.16–3.20 s, right after the group exits (3.04–3.07 s); next turn OK |
| C7 · wedged worker (SIGSTOP); Shell leader exited | the leader is a zombie that pins its pgid | same; ledger deleted 20 ms (Linux 82 ms) after the worker SIGKILL |
| C8 · a Shell call that just finishes | — | its group leaves the ledger 372 ms after the group exits (prune interval ≤1 s) |
On head, every run wrote exactly one <incarnation>.json (244 bytes on macOS) while the Shell ran. It names the Shell leader's pid as the group and records hostPid as the Managed child, and it is on disk before the call settles. Base never writes a ledger.
Items for the author (none blocks merging)
1. Cancelling a command with a TERM-resistant member now blocks the session, even after the stop has been proven (C1).
- What happens:
shellExecutionServicecancels by sending TERM to the group and escalates to SIGKILL only while the leader is still alive. A member that ignores TERM and HUP therefore outlives the 10 s evidence budget. The call becomesunknown, the host blocks the session and stops the worker, and the worker stop SIGKILLs the member. The ledger is deleted at 10.14 s, before the client even receives theturn_errorat 10.21 s. From then on every turn is refused withmanaged_runtime_outcome_unknown. - Why it matters: this is exactly what the design says ("a group that outlives the budget makes the call's outcome unknown: the session blocks, the host stops the worker"). In product terms, though, cancelling something like a
nohup'd service with a slow shutdown costs the user the whole session. - Two options, either one enough:
- SIGKILL the recorded group once the TERM grace has passed, while its identity is still fresh, so the cancel settles
cancelledwith proof. - Keep
unknown, but lift the block once the close-time sweep proves the group gone.
- SIGKILL the recorded group once the TERM grace has passed, while its identity is still fresh, so the cancel settles
- What works as intended: the budget itself. In C1b, a member with a 3 s graceful shutdown gets an honest
cancelledat 3.16 s. Base reportscancelledat 0.16 s while that member is still running.
2. An unreadable ledger quarantines new Managed sessions until someone deletes it by hand, and the client is told something unrelated (C6).
- What happens: a 0-byte or truncated
*.jsonunder<projectTempDir>/managed-runtime/makes every new session return 409 at 2 s, 10 s and 30 s. Each reaper tick re-reads the file and throws again. Deleting the file lifts the quarantine within 1.6–3.7 s. This is the end state triage finding pre-release: fix ci #1 worried about. It is real, though it arises by a different route (see below). - Operator-facing gaps:
- For a new session, the client sees
409 {"error":"This session cannot be resumed with the current execution engine.", "errorKind":"session_execution_engine_unavailable"}. - The actual reason and the file path appear only in the Managed child's stderr:
-32024 The Managed engine is quarantined: … deadbeef-empty.json cannot be read; nothing it held can be proven.
- For a new session, the client sees
- Suggestion: the design already leaves Bridge/daemon propagation to M6. It would still help to record in the M5c section that an unreadable ledger holds the quarantine until it is removed, and how to remove it, and to have M6's refusal name the ledger.
3. Four mutants survive the PR's suites, and each has a real-stack consequence. I ran 33 single-line mutants against the suites that own the code; 26 were killed. For W8, E3 and A2 I measured the consequence by mutating a copy of the shipped bundle and rerunning the same rig. Candidate tests for W8, E3 and L7 are below; each passes on head and is the only failure under its mutant.
| Mutant | What it removes | Consequence |
|---|---|---|
W8 (managed-runtime-session-worker.ts) |
quarantine.report(reason) when the startup sweep fails |
C5 and C6 lose the quarantine entirely: every POST /session returns 200 while the undatable member or unreadable ledger is there. The behaviour shown in figure 2 has no test. |
E3 (managed-runtime-tool-executor.ts) |
killOutstanding() in the worker's close() |
C3: the worker exits at 10.1 s, but the member is still alive 25 s later and the ledger stays; only some future child's startup sweep would reap it. The existing close() test survives E3 because its Shell dies of the cancel's TERM first. |
A2 (managed-runtime-attestation-worker.ts) |
ledger?.watch() |
a finished call's group is still named 8 s later, where head drops it 372 ms after the group exits. That stale entry is what a witnessed sweep would SIGKILL without an identity check (triage #2). It could be pinned by asserting, in the process suite, that after a completed Shell call the ledger's groups empties within ~2 s while the worker lives. |
L7 (managed-runtime-ledger.ts) |
isLedgerWorker's age check (marker alone) |
a stale sweep would SIGKILL a live sibling worker that sits on a recycled pid (triage #3: the check is load-bearing, and nothing pins it). |
The other three survivors:
- C1 (derived
Config.reportManagedEngineQuarantineno longer delegates): the core test reports on the root Config and only clears through a derived one. Today's product passes the root Config, so this is low risk; callingderiveConfig(config).reportManagedEngineQuarantine(reason)in the test would pin it. - L12 (write without temp + rename): atomicity only shows under a crash mid-write, so this survivor is expected.
- W9 (own ledgers not added to the skip set): masked by
holdsForLiveHost, since the live child is the ledger's host. Effectively equivalent.
On the specific check triage stage 2 asked for: removing the waitForGroupExit block (E1) is killed, but only by the doubled "outlives the evidence budget" test. The named real-process test settles a cancel only once the whole process group is gone still passes under E1, because its single-process group dies together with its leader. C1b is the real-process discriminator.
Candidate tests (W8, E3, L7) — each passes on head, fails only under its mutant
diff --git a/packages/cli/src/serve/managed-runtime-session-worker.test.ts b/packages/cli/src/serve/managed-runtime-session-worker.test.ts
--- a/packages/cli/src/serve/managed-runtime-session-worker.test.ts
+++ b/packages/cli/src/serve/managed-runtime-session-worker.test.ts
@@ -1150,6 +1150,61 @@ describe.skipIf(process.platform === 'win32')(
expect(() => process.kill(pid!, 0)).toThrow();
});
+ it("quarantines the engine while an earlier child's ledger stays unproven", async () => {
+ const previous = process.env['QWEN_RUNTIME_DIR'];
+ process.env['QWEN_RUNTIME_DIR'] = path.join(root, 'runtime');
+ try {
+ const sweeperConfig = new Config({
+ sessionId: '11111111-2222-3333-4444-666666666666',
+ targetDir: root,
+ cwd: root,
+ debugMode: false,
+ model: 'test-model',
+ usageStatisticsEnabled: false,
+ telemetry: { enabled: false },
+ deferTelemetryInitialization: true,
+ });
+ const report = vi
+ .spyOn(sweeperConfig, 'reportManagedEngineQuarantine')
+ .mockImplementation(() => undefined);
+ const lift = vi
+ .spyOn(sweeperConfig, 'clearManagedEngineQuarantine')
+ .mockImplementation(() => undefined);
+ const ledgerDir = path.join(
+ sweeperConfig.storage.getProjectTempDir(),
+ 'managed-runtime',
+ );
+ await mkdir(ledgerDir, { recursive: true });
+ // Debris the startup sweep can neither trust nor resolve.
+ const workFile = path.join(ledgerDir, 'unreadable.json');
+ await writeFile(workFile, '', 'utf8');
+ environment = createManagedRuntimeEnvironment(sweeperConfig, () => ({
+ command: process.execPath,
+ args: [script],
+ env: { ...process.env, FAKE_MODE: 'ok', FAKE_LOG: logFile },
+ }));
+ const deadline = Date.now() + 10_000;
+ while (report.mock.calls.length === 0) {
+ if (Date.now() > deadline) throw new Error('never quarantined');
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ const reason = report.mock.calls[0]![0];
+ expect(lift).not.toHaveBeenCalled();
+
+ // Once the debris is gone the reaper proves the directory and lifts
+ // the quarantine with the reason it raised.
+ await rm(workFile, { force: true });
+ while (lift.mock.calls.length === 0) {
+ if (Date.now() > deadline) throw new Error('never lifted');
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ expect(lift).toHaveBeenCalledWith(reason);
+ } finally {
+ if (previous === undefined) delete process.env['QWEN_RUNTIME_DIR'];
+ else process.env['QWEN_RUNTIME_DIR'] = previous;
+ }
+ });
+
it('sweeps the stale worker ledgers of an earlier child when it starts', async () => {
const previous = process.env['QWEN_RUNTIME_DIR'];
process.env['QWEN_RUNTIME_DIR'] = path.join(root, 'runtime');
diff --git a/packages/cli/src/serve/managed-runtime-tool-executor.test.ts b/packages/cli/src/serve/managed-runtime-tool-executor.test.ts
--- a/packages/cli/src/serve/managed-runtime-tool-executor.test.ts
+++ b/packages/cli/src/serve/managed-runtime-tool-executor.test.ts
@@ -195,6 +195,32 @@ describe.skipIf(process.platform === 'win32')(
strayGroups.delete(pgid);
});
+ it('close() SIGKILLs a member that outlived the cancel and removes the ledger', async () => {
+ // The leader dies of the cancel's TERM, so no SIGKILL escalation runs;
+ // the member ignores TERM and HUP. Only close()'s own kill reaches it.
+ const exec = executor(300);
+ const member = `"${process.execPath}" -e 'process.on("SIGTERM",()=>{});process.on("SIGHUP",()=>{});setInterval(()=>{},1000)'`;
+ const input = {
+ command: `${member} & ${LONG_RUN}`,
+ description: 'leader with a TERM-ignoring member',
+ };
+ const running = exec.execute(
+ reference('call-4b', input),
+ 'run_shell_command',
+ input,
+ );
+ void running.catch(() => undefined);
+ const pgid = await recordedGroup();
+ strayGroups.add(pgid);
+ // Let the member start and install its handlers.
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ await exec.close();
+ expect(processGroupLiveness(pgid)).toBe('gone');
+ expect(existsSync(ledgerFile)).toBe(false);
+ strayGroups.delete(pgid);
+ });
+
it('leaves a Write outside the ledger and settles without evidence', async () => {
const exec = executor();
const input = {
diff --git a/packages/cli/src/serve/managed-runtime-ledger.test.ts b/packages/cli/src/serve/managed-runtime-ledger.test.ts
--- a/packages/cli/src/serve/managed-runtime-ledger.test.ts
+++ b/packages/cli/src/serve/managed-runtime-ledger.test.ts
@@ -807,6 +807,46 @@ describe('Managed Runtime ledger', () => {
expect(existsSync(workFile)).toBe(true);
});
+ it('resolves a recycled worker pid that a younger Runtime worker now holds', async () => {
+ // The recorded worker started an hour ago; its pid now answers for a
+ // `managed-runtime-worker` that is seconds old: another session's
+ // live worker on a recycled id. The marker alone must not get it killed.
+ const workFile = path.join(root, 'ledger.json');
+ testInternals.writeLedgerDocument(
+ workFile,
+ {
+ pid: 101,
+ pgid: 101,
+ incarnation: 'i',
+ startedAt: Date.now() - 3_600_000,
+ },
+ [],
+ );
+ const signal = vi.fn(() => 'sent' as const);
+ await sweepWorkerLedger(workFile, {
+ proofTimeoutMs: 300,
+ sys: {
+ platform: 'linux',
+ liveness: (id) => (id === 101 ? 'alive' : 'gone'),
+ signal,
+ table: () =>
+ new Map([
+ [
+ 101,
+ {
+ pid: 101,
+ pgid: 101,
+ runningMs: 5_000,
+ args: 'node dist/cli.js managed-runtime-worker',
+ },
+ ],
+ ]),
+ },
+ });
+ expect(signal).not.toHaveBeenCalled();
+ expect(existsSync(workFile)).toBe(false);
+ });
+
it('does not hold a ledger whose named host is younger than the worker', async () => {
// A process younger than the worker it is named as parenting is an
// impostor on a recycled pid: the orphaned worker belongs to the sweep.4. Minor (C3). When the Managed child dies and the group has a TERM-resistant member, the orphaned worker lingers 10.1–10.4 s before its killOutstanding, because its close() first waits out the cancel-evidence budget for the aborted call. Cleanup does complete. Signalling before waiting would make it immediate.
Triage findings re-checked
- pre-release: fix ci #1 (torn write from concurrent sweeps): the in-process mechanism does not reproduce.
writeLedgerDocumentiswriteFileSync+renameSyncwith noawaitbetween them, so two sweeps in one process cannot interleave inside it. In 3 × 400 witnessed sweeps of one ledger, all overlapping in their proof wait (the ledger held a root-owned group, so SIGKILL got EPERM and every sweep rewrote the file), the final file was always readable.- Across processes, the fixed
.tmpname is not safe. With two processes writing documents of different lengths, about 3 % of concurrent reads (3.1–3.3 %) saw an unreadable file and 20–25 % of renames hit ENOENT. In 40 of 40 trials the final file still parsed, so the worst case is a transient quarantine that the next reaper tick lifts. The only product path I found with two writers is two Managed hosts on one project sweeping the same dead ledger, so a per-writer staging name is still a cheap hardening. - The permanent outcome (item 2) can be reached by other routes, for example a 0-byte file left by a power loss without fsync.
- Where is the config saved? #2 (witnessed sweep without an identity check): narrow, and a wedged worker does not widen it. In C7 the worker was SIGSTOPped after its Shell leader exited. The leader stayed a zombie that holds the pgid (
kill(-pgid,0)returns EPERM on macOS and succeeds on Linux; either way the id is not free). So the pgid cannot be recycled behind a stale entry while the worker is wedged. A bystander kill needs the reap, a new group leader reusing the pgid and the worker's death all inside the ≤1 s prune window, and that window depends on the prune being wired up (A2 above). Whether the design sentence should be softened is the author's call. - 如何自定义密钥文件 .env可能与其他文件冲突 #3: confirmed. The age check is load-bearing and no test covers it (L7 above).
Tests on the head tree
| Suite | macOS | Linux arm64 |
|---|---|---|
| serve: ledger 39, executor 6, session-worker 73, process 7, env-guard 3 | 128/128 | 128/128 |
acpAgent.test.ts |
843/843 | 843/843 |
core managed-session-log.test.ts |
53/53 | 52/53 + 1 skipped (the existing chmod case skips as root) |
Not covered
Windows (unit doubles only, as the PR states); the setsid/double-fork residual; the spawn-to-pid-callback window; Bridge/daemon quarantine propagation (M6).
中文版本
真实环境验证 — M5c 物理停止 @ 6cb680dca3
合并参考:支持合并。 以合并基点 2c591ecc08 为对照臂,在 macOS arm64 与 Linux arm64 上,用真实 daemon、真实 Managed 子进程、它拉起的真实 Runtime worker 和真实 PTY Shell 进程组,端到端复现了 M5c 的全部主张。base 在每种崩溃形态下都泄漏进程;head 在每种形态下都把它停掉,无法定年时则持住进程组并隔离引擎。取消证据与隔离/解除的行为与设计文档完全一致。
下面各项都不阻塞合并,因为 M6 之前引擎是惰性的。其中两项会改变 M6 启用后用户或运维看到的东西;另有四个变异体在 PR 自带套件下存活,且在真实链路上能测出后果。建议在 M6 依赖这部分代码之前定下来。
怎么跑的
- 两棵树: base = 合并基点
2c591ecc08,head =6cb680dca3,各自npm run build && npm run bundle。 - 链路: 各树自己的
createServeApp(packages/cli/dist),开experimentalPairedEngines,经managedExecutionEngine注入点注入 Managed 引擎。这个注入点代替 M6 的注册,是唯一被模拟的部分。子进程是打包产物dist/cli.js --acp --acp-execution-engine managed,Runtime worker 是它自己拉起的那个。脚本化的假 OpenAI 模型驱动run_shell_command。 - 残留成员: 同时忽略 SIGTERM 与 SIGHUP 的组成员(命令见英文部分)。取消路径的 TERM 和 PTY 挂断都打不到它;普通
sleep在两臂上都会被挂断信号杀掉,分不出两臂差别。 - 主机: macOS 每个场景每臂两遍,表中区间覆盖两遍;Linux arm64(Armbian jammy,内核 5.10,rk3588,Node 24.13)每臂一遍,C1–C7 的 A/B 差异与 macOS 全部一致。这补上了 PR「Tested on」表里 Linux 的
⚠️ 。
| 场景 | base | head |
|---|---|---|
| C2 · 调用中途 SIGKILL worker | 成员存活,被 init 收养,仍在 Shell 进程组里 | 宿主目击式清扫在 26–27 ms(Linux 51 ms)内 SIGKILL 成员;账本删除 |
| C3 · SIGKILL Managed 子进程 | worker 0.26 s(Linux 0.56 s)退出;成员存活 | worker 滞留 10.1–10.4 s 后 SIGKILL 成员并删除账本 |
| C4 · 两者同时 SIGKILL | 下一次 POST /session 及其中的工具回合之后成员仍存活 |
下一次 POST /session 后 0.66–0.74 s(Linux 2.3 s),由新子进程的启动清扫杀掉 |
| C5 · 无法定年的进程组 | 新会话照常准入;成员无人跟踪 | 成员退出(30.5–31.7 s)前每次 POST /session 都返回 409;之后 0.7–0.9 s 恢复准入 |
C6 · 账本目录里有 0 字节或被截断的 *.json |
不适用 | 2 s、10 s、30 s 都是 409,不会自行解除;手工删除文件后 1.6–3.7 s 恢复 |
| C1 · 取消;成员忽略 TERM 与 HUP | 0.16–0.26 s cancelled;会话删除后成员仍泄漏 |
10.2–10.5 s 时 turn_error managed_runtime_outcome_unknown;成员在 10.1–10.4 s 被 SIGKILL;会话被阻塞 |
| C1b · 取消;成员收到 TERM 后 3 s 退出 | 0.11–0.16 s cancelled,成员其实仍在运行 |
3.16–3.20 s cancelled,恰在进程组退出(3.04–3.07 s)之后;下一回合正常 |
| C7 · worker 被 SIGSTOP,Shell 组长已退出 | 组长成为僵尸并占住 pgid | 同左;worker 被 SIGKILL 后 20 ms(Linux 82 ms)内删除账本 |
| C8 · 一次正常结束的 Shell 调用 | — | 进程组退出后 372 ms 从账本中移除(prune 间隔 ≤1 s) |
head 的每次运行中,Shell 运行期间都恰好写出一个 <incarnation>.json(macOS 上 244 字节)。它以 Shell 组长 pid 作为进程组,hostPid 记为 Managed 子进程,并在调用结算之前就已落盘。base 从不写账本。
给作者的事项(都不阻塞合并)
1. 取消带有抗 TERM 成员的命令,现在会阻塞会话,即使停止已经被证明(C1)。
- 发生了什么:
shellExecutionService取消时对进程组发 TERM,只有组长仍存活时才升级为 SIGKILL。因此忽略 TERM 与 HUP 的成员会熬过 10 s 证据预算。调用变成unknown,宿主阻塞会话并停掉 worker,worker 停止时 SIGKILL 掉成员。账本在 10.14 s 就已删除,早于客户端在 10.21 s 收到turn_error。此后每一回合都以managed_runtime_outcome_unknown被拒。 - 为什么值得注意: 这与设计文档完全一致("a group that outlives the budget makes the call's outcome unknown: the session blocks, the host stops the worker")。但从产品角度看,取消一个关停很慢的
nohup服务之类的命令,代价是整个会话。 - 两种做法,任选其一即可:
- TERM 宽限期过后、趁进程组身份还新鲜时 SIGKILL 账本记录的进程组,取消即可带着证明结算为
cancelled。 - 保留
unknown,但在关闭时的清扫证明进程组已死后解除阻塞。
- TERM 宽限期过后、趁进程组身份还新鲜时 SIGKILL 账本记录的进程组,取消即可带着证明结算为
- 按预期工作的部分: 预算本身。C1b 中 3 s 优雅退出的成员得到了如实的
cancelled(3.16 s);base 在 0.16 s 就报告cancelled,而那时成员还在运行。
2. 读不出的账本会让新 Managed 会话一直被隔离,直到有人手工删除,而客户端看到的提示与此无关(C6)。
- 发生了什么:
<projectTempDir>/managed-runtime/下一个 0 字节或被截断的*.json,会让每个新会话在 2 s、10 s、30 s 都返回 409。reaper 每次重读都重新抛错。删除文件后 1.6–3.7 s 内解除。这正是 triage 发现一担心的终态,它确实存在,只是成因不同(见下)。 - 运维侧的缺口:
- 客户端对一个新会话看到的是
409 "This session cannot be resumed with the current execution engine."。 - 真实原因与文件路径只出现在 Managed 子进程的 stderr 里。
- 客户端对一个新会话看到的是
- 建议: 设计文档已把 Bridge/daemon 侧的传播留给 M6。仍建议在 M5c 一节记下"读不出的账本会一直持住隔离、直到被移除"以及怎么移除,并让 M6 的拒绝信息点名该账本。
3. 四个变异体在 PR 自带套件下存活,且在真实链路上都有后果。 33 个单行变异体,针对各自所属的套件运行,杀死 26 个。W8、E3、A2 的后果是变异打包产物副本后用同一装置实测得到的。W8、E3、L7 的候选测试见英文部分的折叠块:在 head 上通过,在各自变异体下是唯一失败项。
- W8(启动清扫失败时不再隔离):C5 和 C6 的隔离完全消失,期间
POST /session全是 200。图 2 展示的行为没有任何测试钉住。 - E3(worker 的
close()不再killOutstanding):C3 中 worker 在 10.1 s 退出,但成员 25 s 后仍存活,账本也留着,只能等将来某个子进程的启动清扫。现有的close()测试之所以没发现,是因为其中的 Shell 先被取消的 TERM 杀掉了。 - A2(worker 不调用
ledger.watch()):一次正常结束的调用,其进程组 8 s 后仍挂在账本里;head 在进程组退出后 372 ms 就移除。这条过期条目正是目击式清扫会不经身份校验就 SIGKILL 的对象(triage 发现二)。可在进程套件里断言:Shell 调用完成后,worker 存活期间账本的groups约 2 s 内清空。 - L7(
isLedgerWorker只看标记、不看年龄):过期清扫会 SIGKILL 一个恰好占用回收 pid 的、活着的兄弟 worker(triage 发现三:年龄检查是承重的,却没有测试钉住)。
其余三个存活变异体:
- C1:core 测试在根 Config 上 report,只通过派生 Config 做 clear;产品目前传入的是根 Config,风险低。测试里改为
deriveConfig(config).reportManagedEngineQuarantine(reason)即可钉住。 - L12:原子性只有在写入中途崩溃时才看得出来,属预期存活。
- W9:被
holdsForLiveHost掩盖(存活的子进程就是该账本的宿主),实际等价。
关于 triage 第二阶段要求的那项检查:去掉 waitForGroupExit 那段(E1)会被杀死,但只靠打替身的「超出证据预算」测试;点名的真实进程测试 settles a cancel only once the whole process group is gone 在 E1 下仍然通过,因为它的进程组只有一个进程,随组长一起退出。C1b 才是能在真实进程上区分两者的见证。
4. 次要(C3): Managed 子进程死亡且进程组里有抗 TERM 成员时,成为孤儿的 worker 要滞留 10.1–10.4 s 才执行 killOutstanding,因为它的 close() 先为被中止的调用等满取消证据预算。清理最终会完成;先发信号再等待即可立即完成。
复核 triage 的发现
- 发现一(并发清扫撕裂写入):所述的进程内机制无法复现。
writeLedgerDocument是writeFileSync加renameSync,两者之间没有await,同一进程内的两次清扫不可能在其中交错。我对同一账本跑了 3 × 400 次目击式清扫,全部在证明等待阶段重叠(账本里是 root 进程组,SIGKILL 得到 EPERM,因此每次都重写文件),最终文件每次都可读。- 跨进程时,固定的
.tmp名并不安全。 两个进程写不同长度的文档时,约 3 %(3.1–3.3 %)的并发读取读到不可解析的文件,20–25 % 的 rename 遇到 ENOENT;但 40/40 次试验的最终文件都可解析,最坏只是一次瞬时隔离,下一个 reaper 周期即解除。我找到的唯一双写入者产品路径,是同一项目上两个 Managed 宿主清扫同一份死账本;按写入者区分暂存名仍是低成本的加固。 - 永久隔离这个终态(事项 2)可经其他途径到达,例如未 fsync 时断电留下的 0 字节文件。
- 发现二(目击式清扫不做身份校验):窗口很窄,worker 卡住也不会把它拉宽。 C7 中,Shell 组长退出后把 worker SIGSTOP。组长一直是占着 pgid 的僵尸(macOS 上
kill(-pgid,0)返回 EPERM,Linux 上调用成功,两种情况下该 id 都没有被释放),所以 worker 卡住期间 pgid 不可能被回收到过期条目背后。误杀旁人需要回收、新组长复用该 pgid、worker 死亡三件事都发生在 ≤1 s 的 prune 窗口内,而这个窗口依赖 prune 已接线(见上面的 A2)。设计文档那句话是否弱化,由作者决定。 - 发现三: 已证实。年龄检查是承重的,且没有测试覆盖(见上面的 L7)。
head 上的测试
| 套件 | macOS | Linux arm64 |
|---|---|---|
| serve(ledger 39、executor 6、session-worker 73、process 7、env-guard 3) | 128/128 | 128/128 |
acpAgent.test.ts |
843/843 | 843/843 |
core managed-session-log.test.ts |
53/53 | 52/53 + 1 跳过(既有的 chmod 用例以 root 运行时跳过) |
未覆盖
Windows(如 PR 所述,仅单元替身);setsid/double-fork 残留;spawn 到 pid 回调的窗口;Bridge/daemon 侧的隔离传播(M6)。
Identity: judge ages inside the boot-clock domain on Linux — ps etime is boot-derived and a wall-clock step must not make a live leader read as a recycled impostor — resolve a recycled id only once no old-enough member of the recorded group remains, probe hostPid as a process whose EPERM holds rather than sweeps a sibling, refresh the identity snapshot after the worker's proof wait, and date every proven group with its own budget rather than one shared serial deadline. Containment: ledger filesystem failures no longer escape into fatal states — an addGroup failure stops the newborn group and fails the call loudly, close and the settle-evidence block contain errors instead of rejecting the shutdown or losing the journal's terminal state, an unreadable ledger retires aside once it outlives the debris age instead of quarantining forever, the .tmp debris unlink joins the per-entry containment, and the 1 Hz watchdog prunes by liveness alone instead of forking a blocking ps each tick. Quarantine: a dedicated managed_engine_quarantined error kind mapped to HTTP 503 with the reason kept at both daemon mappers, replacing the resume-conflict 409 rewrite; a startup sweep failure now has its report test; the reaper backs off to a 30-second cadence. The worker also scrubs the ledger path from the environment before any Shell command inherits it.
Review round 1 — all 34 findings addressedCommit Critical.
Suggestions (28). Unreadable ledgers older than the debris age retire aside instead of quarantining forever; proof budgets are per-group and concurrent; holdsForLiveHost probes a process pid (EPERM holds, never sweeps a sibling); Verification on the fix commit: ledger 44, executor 6, session-worker 70 (a 4-case byte-identical dup removed), process 7, attestation 26, env-guard 3, dispatch-error+error-response 104, config 510, acpAgent 843, core managed-session-log 53 — all pass; |
|
@qwen-code /takeover |
|
🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the 中文说明🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 |
…try holes Round-2 review follow-up: - Sweep verdicts: sweepWorkerLedger/sweepStaleLedgers now answer proven/absent/held/retired, and startLedgerReaper lifts only on the sweep's own proof. A retired unreadable ledger never fires onProven, and a vanished ledger re-probes the groups the last failure named before any lift. - A witnessed sweep consults the live process table again: the exit witness is fresh only at the close it names, so a reaper's retry hours later resolves a provably recycled id without signalling its new holder, while a genuine or undatable survivor is still killed. - recordAgeMs refuses a negative boot-domain age: a stamp ahead of this boot (a ledger that outlived a reboot) falls back to the wall clock instead of inverting the identity guard into an ownership assertion. - addGroup derives the default uptime stamp from the caller's startedAt, so a backdated record keeps both clocks telling one story (fixes the recycled-id test that ran red on Linux). - A quarantine refusal resets the deferred Managed-conversation activation instead of poisoning it, so an existing session can enter its workspace after the reaper proves the stop; terminal refusals stay poisoned. - The 503 refusal summarizes the quarantine client-safely (ledger basename and unproven count) instead of republishing the sweep's absolute paths and live pgids. - An orphaned worker's record omits hostPid (init's pid 1 is no host and the reader refuses it), and waitForGroupExit drops only the record it judged, never one re-recorded on the same id. - close() leaves a ledger its reaper already owns to the reaper. - Design docs (EN+zh-CN) realigned: the quarantine error kind, the reaper's backoff, the retirement path, the liveness-only prune, the fourth accepted hole, and the daemon mapper files in the M5c row.
….json
Deterministic verification rejected the M5c round-2 commit: two
packages/cli tests failed — the review command's git-shim case and the
chafa caching case. Both write an extensionless `#!/usr/bin/env node`
shim whose body uses require()/__dirname__ into os.tmpdir(). A
package.json with "type": "module" in tmpdir's up-tree (this runner's
/tmp/package.json) makes Node load those shims as ES modules, so they
crash before arming or counting. The mermaid fake-mmdc/fake-chafa
helpers share the pattern and fail the same way in that environment.
Each shim directory now gets a {"type":"commonjs"} package.json, pinning
the interpretation the shims were written for. Verified: without the pin
the named tests fail under the polluted tmpdir and pass under a clean
one; with the pin they pass under both, and the full packages/cli suite
is green (38595 passed, 92 skipped).
|
🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下: Address review round — M5c worker ledger (round 3: verification repair)This round's actionable input was the deterministic verification rejection of the round-2 commit ( Same-run verification repair — root cause and fixThe rejection.
Root cause (reproduced, not guessed). Both tests write an extensionless
Fix. Each directory that receives a POSIX node shim now also gets a one-line
Review-feedback dispositions
Mutation probes
Verification
中文说明本轮评审处理——M5c worker ledger(第三轮:验证修复)本轮的可执行输入是确定性验证门对第二轮提交( 同轮验证修复——根因与修复拒绝内容。 packages/cli 的
根因(已复现,非猜测)。 两个测试都把无扩展名的
修复。 每个接收 POSIX node 替身的目录现在同时写入一行
评审反馈处置
变异探针
验证
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧵 Resolved all 61 selected review thread(s). · 已关闭全部选中的 61 条评审线程。 Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。 🧠 Handled by Qwen Code · model/模型 |
|
🔀 Base updated: red check(s) [review-pr] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [review-pr] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
Real-environment verification, round 2 —
|
| Finding | Control | Trigger |
|---|---|---|
R2-1 — the per-ledger reaper keeps exitWitnessed: true on every retry |
ps works: the quarantine lifts 0.2 s after the recycle and the unrelated process keeps running. An in-flight proof saw the group gone before the pgid was reused, so this arm does not exercise the recycled branch |
ps failing: the retry SIGKILLs the unrelated process 10.1 s after the recycle, then counts that death as proof and lifts the quarantine (10.5 s) |
R3-1 — the startup reaper ignores its own proven |
Retries with a table: an in-flight 5 s proof sees the gap before the pgid is reused, and the quarantine lifts correctly | Retries held without a table, the pgid recycled, then the table back: the sweep resolves recycled and deletes the ledger by 10.1 s, yet every POST /session stays 503 for 60 s+. The refusal names the deleted file. It lifts 4.2 s after the unrelated process exits |
R3-2 — process.ppid === 1 read as orphanhood |
Normal host: the ledger records hostPid, sibling B's startup sweep holds it, and A's worker and Shell keep running |
A's Managed child is PID 1 of its namespace. A's ledger is {"worker":{"pid":14}} with no hostPid. B (same namespace) starts an ordinary session, which SIGKILLs A's live worker mid-call. A's turn ends managed_runtime_outcome_unknown |
| New — a PID-1 host never reaps | — | Same PID-1 host, plain worker crash, no sibling. The host's sweep does kill the Shell leader and member, but both stay zombies (Zs/Z) under the PID-1 Node, so the group never reads gone. Result: 503 at 8 s and 28 s, ledger kept, and no lift while that child lives (same end state after R3-2) |
On the new PID-1 row:
- Why it happens: the proofs treat a zombie as alive.
kill(-pgid,0)succeeds on Linux and returns EPERM on macOS, so the group never reads as gone. - Who is exposed: any host that adopts orphans without reaping them.
- Ways out (either works): count an all-zombie group as gone, using the
statcolumn of the table you already read, or require an init and document--init/tini for the image.
A/B on the new head (macOS and Linux)
| Scenario | base 69d060e24c |
head 6afdecc622 (macOS / Linux) |
|---|---|---|
| C2 · SIGKILL the worker mid-call | member survives | member SIGKILLed 78 / 134 ms later |
| C3 · SIGKILL the Managed child | member survives | worker lingers 10.2 / 10.3 s, then kills the member |
| C4 · double death | member survives the next session and a tool turn | killed 1.4 / 2.3 s after the next POST /session |
| C5 · undatable group | all 200 | 503 managed_engine_quarantined, naming the ledger file, until the member exits; 200 at 31.0 / 31.6 s |
C6 · 0-byte *.json in the ledger dir |
n/a | 503 at 3.5 / 11.7 / 31.3 s; 200 2.8 / 3.5 s after a manual delete |
| C1 · cancel; member ignores TERM+HUP | cancelled in 0.5–0.6 s; member leaks |
unchanged: outcome_unknown at 10.3 / 10.6 s, member SIGKILLed at 10.1 / 10.4 s, session stays blocked |
| C1b · cancel; member exits 3 s after TERM | cancelled at 0.26 s while the member still runs |
cancelled at 3.23 / 3.17 s, right after the group exits |
| C8 · a call that just finishes | — | the group leaves the ledger 396 / 236 ms after it exits |
Round-1 items: status
-
Cancel blocks the session (C1): unchanged. This is by design, and it is still the trade-off I'd revisit. The worker stop proves the kill about 100 ms before the client sees the error, yet the session stays blocked.
-
Unreadable ledger (C6): improved, with one asymmetry to know. The refusal is now
503 managed_engine_quarantinedwith a message (for C5 it names the ledger file; for an unreadable one it says only "a ledger it could not read"). After ~60 s the file is set aside as.unreadable. What happens next depends on whether any session is open (macOS):- No session open (C6b): the daemon starts a fresh child per attempt, and that child finds nothing, so new sessions are admitted from ~60 s on.
- A session open (C6c): the reaper's
terminalverdict keeps the quarantine. It returned 503 at 75, 90 and 120 s with the file already set aside, and lifted only when the last session closed and the child restarted.
The design says "stands until the child restarts", which matches C6c. An operator, though, sees no file left to explain the refusal.
-
Test gaps: E3, A2 and W8 are now pinned. L7 still survives: the autofix note says the stepped-clock case covers it, but removing
isLedgerWorker's age check leaves every suite green. My round-1 candidate test still applies cleanly: it passes on this head and is the only failure under L7 (58 tests, 1 failed). -
Orphaned worker lingers about 10 s (C3): unchanged (10.2 s).
Tests and mutation
38 of 43 mutants killed. The set is the 33 from round 1 (five re-anchored to the rewritten code) plus 10 on this round's fixes. L*, E*, A* and N6–N10 ran on macOS; W*, G*, C* and N1–N5 ran on Linux arm64, where a kill counts only through a relevant test (see the flaky test below).
- Newly pinned since round 1: E3, A2 and W8, plus every new mutant except N3. That covers retire, terminal-never-lifts,
hostPid, env scrub, the boot clock, 503 mapping, backoff, close-skip and pid-callback containment. - Survivors:
- L7 — see item 3.
- N3 — the per-ledger reaper's
if (verdict === 'proven') return 'proven'. This is the very branch whose absence in the startup reaper is R3-1, and no test covers it. My rig could not discriminate it either: with the branch removed from the bundle, the R2-1 control still lifted, through the same in-flight proof. - C1 — as in round 1.
- L12 — expected; atomicity only shows under a crash mid-write.
- W9 — masked by
holdsForLiveHost.
Suites on the head:
- macOS: every test file the PR touches is green — ledger 57, executor 10, session-worker 72, process 7, attestation 26, env-guard 3, dispatch-error 33, error-response 72, config 510, acpAgent 844, Session 1141, core 53, plus the three files autofix fixed. One combined run of all 15 files timed out 8 attestation and real-process tests under a load average near 100. Those two files re-run alone passed 33/33, twice.
- Linux arm64: serve suites 172/172 and core 52 plus 1 root-skipped, in the runs whose mutant no test sees (N3, C1). acpAgent failed only on the relevant tests in G1–G3.
- Flaky on Linux:
ManagedToolExecutor physical stop › fails the call and kills the group when the ledger cannot record itasserts'gone'the momentexecutereturns, while the callback'swaitForGroupExitis not awaited. The killed leader can still be an unreaped zombie. It failed 1 of 10 runs alone on the unmodified head, and 6 of 8 under the parallel suites.
Not covered
- Windows.
- The
setsid/double-fork residual. - The spawn-to-pid-callback window.
- The R3-1 trigger with a table that never fails: it needs a slow or failing
pson a retry. A/bin/psthat times out under load would give the same.
中文版本
真实环境验证(第 2 轮)— 6afdecc622
接续第 1 轮(6cb680dca3)。此后 PR 经历了三轮评审处理:4aa2aa50da,autofix 的 ed94543f2f/b3fdaa3ed6,最后合入 main 得到 6afdecc622。下面 A/B 的对照臂是新的合并基点 69d060e24c。
合并参考:机制仍然成立,但建议先修掉三个未决 Critical,或明确推迟到有跟踪的 M6 前后续再合并。 R2-1、R3-1、R3-2 都在真实链路(daemon → Managed 子进程 → Runtime worker → PTY Shell)上端到端复现。其中两个会发出账本根本无权发出的 SIGKILL:一个打到无关进程,一个打到活着的兄弟会话的 worker。
复现过程中还发现了第四个问题,与 R3-2 是同一种部署形态。ACP 宿主是 PID 1 时(本仓库 Dockerfile 以 CMD ["qwen"] 结尾、没有 init),任何一次 worker 崩溃都会让引擎永久隔离。
M6 注册引擎之前,这些都碰不到。第 1 轮的 A/B 结论在 macOS 与 Linux 上都依旧成立,我第 1 轮提的事项大部分已处理。
未决 Critical 的复现
四行都在 Linux arm64(Armbian jammy,内核 5.10,rk3588,Node 24.13)上以 root 身份、在私有 pid + mount 命名空间里运行:
- PID 1 是一个 20 行的 C reaper。
/bin/ps被 bind 成一个包装:标志文件存在时就失败。- pid 回收靠写
/proc/sys/kernel/ns_last_pid,把释放的 pgid 分配给一个无关的setsid进程(每次都是一次成功)。 - 不可证明的进程组由一条 Shell 命令造出:
perlfork 出一个立刻退出的子进程,自己移到别的组、永不回收它。这个僵尸让 Shell 进程组对kill(-pgid,0)一直有应答;杀掉那个父进程即可释放。
命名空间外的东西都不受影响。每一行都有对照臂,对照臂里代码行为正确。
| 发现 | 对照 | 触发 |
|---|---|---|
R2-1 — 单账本 reaper 每次重试都带 exitWitnessed: true |
ps 正常:回收后 0.2 s 隔离解除,无关进程继续运行。正在进行的证明在 pgid 被复用前就看到组已消失,所以这一臂没有走到 recycled 分支 |
ps 失败:重试在回收后 10.1 s SIGKILL 了这个无关进程,再把它的死亡当作证明,于 10.5 s 解除隔离 |
R3-1 — 启动清扫的 reaper 忽略自己的 proven |
有进程表的重试:正在进行的 5 s 证明在 pgid 被复用前就看到组已消失,隔离正常解除 | 重试在无表时持住、pgid 被回收、进程表随后恢复:清扫判为 recycled 并 在 10.1 s 内删掉账本,但每次 POST /session 仍是 503,持续 60 s 以上,拒绝信息里还写着已删除的文件名;无关进程退出 4.2 s 后才解除 |
R3-2 — 把 process.ppid === 1 当作孤儿 |
普通宿主:账本记录了 hostPid,兄弟宿主 B 的启动清扫持住它,A 的 worker 与 Shell 照常运行 |
A 的 Managed 子进程是其命名空间的 PID 1。A 的账本是 {"worker":{"pid":14}},没有 hostPid。B(同一命名空间)开一个普通会话,就在调用中途 SIGKILL 了 A 活着的 worker,A 的回合以 managed_runtime_outcome_unknown 结束 |
| 新发现 — PID 1 宿主从不回收 | — | 同样的 PID 1 宿主,普通的 worker 崩溃,没有兄弟宿主。宿主清扫确实杀掉了 Shell 组长和成员,但二者都在 PID 1 的 Node 下成为僵尸(Zs/Z),组永远读不到「已消失」。结果:8 s、28 s 都是 503,账本保留,该子进程存活期间永不解除(R3-2 之后也是同样的终态) |
关于新发现那一行:
- 原因: 证明逻辑把僵尸当成存活。Linux 上
kill(-pgid,0)调用成功,macOS 上返回 EPERM,所以该组永远读不到「已消失」。 - 受影响的宿主: 任何收养孤儿进程却不回收的宿主。
- 出路(任选其一): 用已经读到的进程表
stat列,把全是僵尸的组算作已消失;或者要求有 init,并在镜像文档里写明--init/tini。
新 head 上的 A/B(macOS 与 Linux)
| 场景 | base 69d060e24c |
head 6afdecc622(macOS / Linux) |
|---|---|---|
| C2 · 调用中途 SIGKILL worker | 成员存活 | 78 / 134 ms 后 SIGKILL 成员 |
| C3 · SIGKILL Managed 子进程 | 成员存活 | worker 滞留 10.2 / 10.3 s,随后杀掉成员 |
| C4 · 两者同时 SIGKILL | 下一次会话及其中的工具回合之后成员仍存活 | 下一次 POST /session 后 1.4 / 2.3 s 杀掉 |
| C5 · 无法定年的进程组 | 全部 200 | 503 managed_engine_quarantined,消息点名账本文件,直到成员退出;31.0 / 31.6 s 恢复 200 |
C6 · 账本目录里有 0 字节的 *.json |
不适用 | 3.5 / 11.7 / 31.3 s 都是 503;手工删除后 2.8 / 3.5 s 恢复 200 |
| C1 · 取消;成员忽略 TERM 与 HUP | 0.5–0.6 s cancelled,成员泄漏 |
没变:10.3 / 10.6 s outcome_unknown,成员在 10.1 / 10.4 s 被 SIGKILL,会话保持阻塞 |
| C1b · 取消;成员收到 TERM 后 3 s 退出 | 0.26 s cancelled,成员其实还在运行 |
3.23 / 3.17 s cancelled,恰在进程组退出之后 |
| C8 · 一次正常结束的调用 | — | 进程组退出后 396 / 236 ms 从账本移除 |
第 1 轮事项的状态
-
取消会阻塞会话(C1):没变。 这是设计如此,仍是我建议重新权衡的取舍:worker 停止时已经在客户端看到报错前约 100 ms 证明了进程已被杀,会话却仍被阻塞。
-
读不出的账本(C6):有改善,但有一个不对称要知道。 拒绝现在是
503 managed_engine_quarantined,带说明(C5 会点名账本文件;读不出的账本只写「a ledger it could not read」)。约 60 s 后文件被改名为.unreadable。之后的行为取决于有没有会话开着(macOS 实测):- 没有会话开着(C6b): daemon 每次请求都起一个新子进程,新子进程什么也找不到,所以约 60 s 起恢复准入。
- 有会话开着(C6c): reaper 的
terminal结论会保持隔离。即使文件已被移走,75、90、120 s 仍是 503,直到最后一个会话关闭、子进程重启才解除。
设计写的是「保持到子进程重启」,与 C6c 一致;但运维已看不到任何能解释这次拒绝的文件。
-
测试缺口:E3、A2、W8 现在都已被钉住。 L7 仍然存活:autofix 说墙钟阶跃用例已覆盖它,但去掉
isLedgerWorker的年龄检查后所有套件仍为绿。我第 1 轮的候选测试仍能原样应用:在本 head 通过,且是 L7 下的唯一失败项(58 个测试 1 个失败)。 -
孤儿 worker 滞留约 10 s(C3):没变(10.2 s)。
测试与变异
43 个变异体杀死 38 个。 这组变异体是第 1 轮的 33 个(其中 5 个按重写后的代码重新定位)加上针对本轮修复的 10 个。L*、E*、A*、N6–N10 在 macOS 上跑;W*、G*、C*、N1–N5 在 Linux arm64 上跑,Linux 上只有被相关测试杀死才计数(不稳定测试见下)。
- 第 1 轮以来新被钉住: E3、A2、W8,以及除 N3 外全部新变异体。覆盖了 retire、terminal 不解除、
hostPid、环境变量擦除、启动时钟、503 映射、退避、close 跳过、pid 回调兜底。 - 存活:
- L7 — 见事项 3。
- N3 — 单账本 reaper 的
if (verdict === 'proven') return 'proven'。正是这个分支在启动清扫 reaper 里的缺失构成了 R3-1,却没有任何测试覆盖它。我的装置也没能区分它:从打包产物里删掉这个分支后,R2-1 的对照臂仍经同样的在途证明解除了隔离。 - C1 — 同第 1 轮。
- L12 — 预期存活;原子性只有在写入中途崩溃时才看得出来。
- W9 — 被
holdsForLiveHost掩盖。
head 上的测试:
- macOS: PR 改动的全部测试文件都通过——ledger 57、executor 10、session-worker 72、process 7、attestation 26、env-guard 3、dispatch-error 33、error-response 72、config 510、acpAgent 844、Session 1141、core 53,以及 autofix 修复的三个文件。把 15 个文件一次跑完时,在负载约 100 下有 8 个 attestation 与真实进程用例超时;这两个文件单独重跑两次都是 33/33。
- Linux arm64: 在变异对测试不可见的运行(N3、C1)里,serve 套件 172/172,core 52 个通过、1 个以 root 运行而跳过。acpAgent 只在 G1–G3 中出现相关测试的失败。
- Linux 上的不稳定测试:
ManagedToolExecutor physical stop › fails the call and kills the group when the ledger cannot record it在execute返回的瞬间就断言组已'gone',而回调里的waitForGroupExit并未被 await,被杀的组长此时可能仍是未回收的僵尸。在未修改的 head 上单独跑 10 次失败 1 次,并行套件下 8 次失败 6 次。
未覆盖
- Windows。
setsid/double-fork 残留。- spawn 到 pid 回调之间的窗口。
- 进程表从不失败时的 R3-1 触发:它需要某次重试时
ps变慢或失败;负载下超时的/bin/ps效果相同。
…sical-stop # Conflicts: # packages/cli/src/ui/utils/mermaidImageRenderer.test.ts
…ve holes Review round for #13352: - the startup reaper now accepts its own sweep's proof: the directory sweep returns 'proven' only for a ledger it judged clean itself, and 'absent' (nothing judged) falls through to the last-failure liveness probe, so a deleted ledger still holds the quarantine until the groups it named die while a sweep-proven clean bill lifts it. - a retired ledger now rejects with LedgerSweepRetiredError instead of resolving 'retired', so every production caller — close, the exit hook, the failed-launch sweep, the startup sweep — quarantines it, and both reaper closures map the rejection (seeded from the arming one, since the aside is judged by no later pass) to 'terminal'. - a bookkeeping unlink that fails (aged .tmp debris, or the post-proof ledger removal) is logged and skipped rather than escalated into an unprovable stop. - queryProcessTable pins COLUMNS=4096 and passes -ww so procps never truncates the args column the identity markers are matched against. - the reaper's retries no longer carry the exit witness: the witness is fresh only at the exit it names, and a retry minutes later must judge by the live table alone rather than SIGKILL a recycled id's new holder. - a pid-1 host is recorded as-is and told from init by the live table's argv, never by liveness alone; the reader accepts hostPid >= 1, and the hold never applies to a ledger this process itself parented. - the single-ledger reaper maps an empty last-named set to 'terminal': an unreadable ledger that is then deleted is no proof of anything. - the reaper backoff doubles on every unresolved retry, thrown or resolved-'unproven' alike; the group judgement re-reads the process table only after a proof wait aged the first snapshot, and keeps the paid-for snapshot when the re-read fails. Each new guard carries a mutation-probed witness test; the design doc records the pid-1 reading, the undatable-group hole, and the Windows attribution qualifier in both languages. Co-authored-by: Qwen-Coder <[email protected]>
Round 5 disposition —
|
…sical-stop One upstream commit since the last merge: d03eafd (#13168, Hosted turns carry the Workspace's project context). Two conflict blocks in managed-runtime-tool-executor.test.ts, both kept in union with upstream ordering: the import banner (execFileSync, mkdir, symlink vs existsSync, tmpdir; one vitest import deduplicated) and the physical stop describe body beside upstream's hasMkfifo helper and the MANAGED_WORKSPACE_CONTEXT_FILE_CHARS import. Two of upstream's new readWorkspaceContext tests were red on this machine even before the merge: both assert path identity against plain tmpdir spellings while the executor compares realpath-resolved paths, and on macOS tmpdir is a symlink layer (verified: mkdtemp join spelling never equals its realpath). Upstream CI never sees it — the Test (macos-latest) lane is skipped on PRs, exactly as it was on #13168. The fixtures now compare in the resolved domain (realpath the estate and the spy targets); behaviour on Linux, where the spellings are identical, is unchanged.
qwen-code-review-bot
left a comment
There was a problem hiding this comment.
[Critical] Blocking finding(s) follow.
Partially reviewed — gaps disclosed. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.
Unresolved, please confirm:
- [Critical] R1-1
managed-runtime-ledger.tsfixed staging-path interleave — could not be re-traced this round: the reverse-audit loop stopped before round 3 on the review time budget and no verifier ruled on it - [Critical] 3 entries — could not be re-traced this round for the same budget reason:
- R1-36
managed-runtime-ledger.tsaddGroup memory-before-durable ordering - R1-5
managed-runtime-session-worker.tslastNamed replace-vs-accumulate asymmetry - R1-6
managed-runtime-session-worker.tsper-tickjudgedset discarding a proved file
- R1-36
- [Critical] R1-33
managed-runtime-session-worker.tsemptyremainingconflating a transient read with a never-readable ledger — the author ruled it the other direction and the slice owner adopted that reading; this round could not re-trace it under …
Not reviewed: the reverse-audit convergence pair's 68 findings — their verifier batch was cut off by the workflow wall clock (6 of 9 shards returned, 3 aborted), so they remain tagged unverified and are not treated as confirmed.
Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": did not execute any suite (no test run to confirm the spawn-path freeze empirically); did not read ManagedToolExecutor.close() to confirm it calls ledger.kil…; "agent reverse-audit (round 1)": did not verify "The POSIX behavior is verified with real processes, attribution included" against the actual assertions in packages/cli/src/serve/managed-runti…; "agent reverse-audit (round 1)": did not trace "is reported through the session Config to the Managed host" end to end (the core config.ts report point → acpAgent.ts quarantine arm → the -3…; "agent reverse-audit (round 1)": the await chain from bridge.closeSession(sessionId) to ManagedSessionRuntimeWorker.close() — I verified close() awaits sweepLedgerOnce per ledger path (…; "agent reverse-audit (round 2)": tracing whether any production v3 tool-result client (hosted broker executeV3 / the containerized runtime worker) can reach a worker that was handed MANAGED_…, and 4 more.
Not reviewed: reverse audit — stopped before round 3 by the review time budget.
Not reviewed: "agent verify" — pointed at diff lines it never opened: it made tool calls, but none of them read the diff.
— [unverified] tag when the loop ended — the verifier never ruled on them, and they are not confirmed.
Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:
packages/cli/src/serve/managed-runtime-session-worker.ts:1212 — [review] The sweep is called "background" but its identity reads are synchronous execFileSync forks on the Managed child's single thread, one to two per ledger file, with no cr…packages/cli/src/serve/managed-runtime-ledger.test.ts:123 — [review] killGroup's fallback SIGKILLs a bare pid after the group kill fails, which on POSIX can only mean the group is already gone — so the pid is free and may have been recycled…packages/cli/src/serve/managed-runtime-ledger.test.ts:2230 — [review] sweepStaleLedgers' third parameter onFileJudged — the callback the production reaper uses to decide which ledgers it may count as proven — has zero coverage: no call in t…packages/cli/src/serve/managed-runtime-session-worker.process.test.ts:765 — [review] The restarted bridge re-spells, verbatim, the ~40-line launch environment and createAcpSessionBridge option set that beforeEach already builds (L204–247), …packages/cli/src/serve/managed-runtime-session-worker.test.ts:786 — [review] The sweeps the ledger of a worker that exits between calls test orders its ledger write against the worker's exit only by a 20 ms wall-clock timer — if the write l…packages/cli/src/serve/managed-runtime-session-worker.test.ts:2011 — [review] Phase 2's single overwrite of workFile races the reaper's retry sweep, which unconditionally rewrites the same file back to the phase-1 truth.packages/cli/src/serve/managed-runtime-session-worker.test.ts:2093 — [review] The 750 ms margin against TMPDEBRISAGEMS decides which path the test covers, and losing the race is silent — the test still passes.packages/cli/src/serve/managed-runtime-session-worker.test.ts:2593 — [review] The added tests hand-roll the spawn/reap and sweeper-config blocks instead of hoisting the helpers this file already has, and one hand-rolled copy leaves statemen…docs/design/2026-09-27-ordinary-host-managed-engine.md:255 — [review] The M5 exit-check row presents itself as the complete list of holes the M5c section accepts (three), but the M5c section this same diff adds names five — so the acceptanc…packages/cli/src/serve/managed-runtime-tool-executor.test.ts:239 — [review] The liveness assertion runs with nothing waiting for the SIGKILL to be reaped; it passes only because the error-settle path happens to take a few milliseconds.packages/cli/src/serve/managed-runtime-tool-executor.test.ts:382 — [review] The cleanup SIGKILLs the first 2-6-digit number it can regex out of the Shell's rendered result text — an identity the queryProcessTable() scan nine lines below alr…docs/design/2026-09-27-ordinary-host-managed-engine.md:1186 — [review] The section's closing paragraph gives a lift condition ("until the groups it still names provably die (or an operator clears the debris by hand)") to exactly the cases t…docs/design/2026-09-27-ordinary-host-managed-engine.md:1138 — [review] The startup sweep's worker-pid decision is documented as binary (provably the worker → swept with it; anything else → recycled and resolved), but the code has a third, f…docs/design/2026-09-27-ordinary-host-managed-engine.md:1288 — [review] The paragraph that declares M5c's daemon-side public surface, and the M5 row of the "Files and consumers" table, both omit a second daemon-side change the slice actually…docs/design/2026-09-27-ordinary-host-managed-engine.zh-CN.md:925 — [review] The M5c slice inventory and its "only one daemon-side mapping" claim omit serve/conversations/standalone-session-service.ts, a production daemon-side file this PR c…packages/cli/src/acp-integration/acpAgent.ts:887 — [review] The quarantine summary's contract with LedgerSweepUnprovenError is a hand-copied structural duplicate that no test in either package can pin, because the lint boundary forbids the …packages/cli/src/acp-integration/session/Session.ts:859 — [review] The same PR adds a byte-identical copy of this predicate on the other side of an enforced package boundary (packages/cli/src/serve/conversations/standalone-session-service.t…packages/cli/src/serve/managed-runtime-attestation-worker.ts:220 — [review] Nothing asserts that a normally settled Shell call's group ever leaves the ledger; the only mechanism that drops it is the 1 Hz watchdog, and the watchdog is pinned…
Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)
中文说明
仅完成部分审查,审查缺口已披露。 建议见行内评论。 1 条建议级发现无法锚定到改动行,已丢弃;此处无需进一步处理。
未决,请确认:共 5 条(原文未翻译,列表见上方英文部分)。
未审查(原文为英文):the reverse-audit convergence pair's 68 findings — their verifier batch was cut off by the workflow wall clock (6 of 9 shards returned, 3 aborted), so they remain tagged unverified and are not treated as confirmed.
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)":did not execute any suite (no test run to confirm the spawn-path freeze empirically); did not read ManagedToolExecutor.close() to confirm it calls ledger.kil…;"agent reverse-audit (round 1)":did not verify "The POSIX behavior is verified with real processes, attribution included" against the actual assertions in packages/cli/src/serve/managed-runti…;"agent reverse-audit (round 1)":did not trace "is reported through the session Config to the Managed host" end to end (the core config.ts report point → acpAgent.ts quarantine arm → the -3…;"agent reverse-audit (round 1)":the await chain from bridge.closeSession(sessionId) to ManagedSessionRuntimeWorker.close() — I verified close() awaits sweepLedgerOnce per ledger path (…;"agent reverse-audit (round 2)":tracing whether any production v3 tool-result client (hosted broker executeV3 / the containerized runtime worker) can reach a worker that was handed MANAGED_…,另有 4 条。
未审查:反向审计——评审时间预算不足,未能开始第 3 轮。
未审查:"agent verify"——启动 prompt 为它指定了 diff 中的行,但它从未打开:有工具调用,却没有一次读取 diff。
— [unverified] 标记——验证者从未对它们作出裁决,它们不算已确认。
收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 18 条(原文未翻译,列表见上方英文部分)。
机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)
— qwen3.8-max via Qwen Code /review (v0.25.0)
|
⏳ Historical-head review — head moved to 中文说明⏳ 历史 head 评审 —— 本次评审进行中 head 移动到了 |
Upstream #13281 landed the doc with 4 badly wrapped table lines; its own lint lane ran a 15 s light profile that never scanned it, so the violation reached main unnoticed and began failing every PR merge ref computed after 2026-10-07T04:45Z (this PR's run on 13d1735 inclusive). Format-only change: `prettier --write` (4 lines); the whole-tree `--check .` now passes, and upstream #13536's version of the same file is already clean, so the next main merge stays green by construction.
…slice Classification and surfaces: a liftable Managed-engine quarantine refusal is translated once, at the throw site in bindAndRelease (both the commit and the release arm), so create and restore alike see the retryable managed_engine_quarantined StandaloneSessionServiceError — restore no longer falls through to working_directory_compromised and its HTTP-409-shaped "retrying is pointless". The create path's own catch passes the already-translated error through instead of rewrapping it as a creation-outcome failure. Both StandaloneSession- ServiceError status ladders (acp-http dispatch and server error-response) now map the code to 503 like the raw child refusal, never the default conflict tier, so 503=retry-later stays the daemon's own convention on both surfaces. Quarantine summary: an unreadable ledger in an aggregate no longer erases the names and counts of every sibling the same sweep identified — concrete entries keep their basename and group count, and the unreadable clause is appended; the unreadable-only shape is the same clause alone, never "ghost.json: 0 process group(s)". Witnesses, each red under its reverting mutation and green restored: a restore-path load/resume whose commit arm refuses twice is reported code managed_engine_quarantined, retryable: true, with the refusal as cause (raw-rethrow flip reds); a 503 case on each mapper (tier-removal flip reds); the named-entries-survive and unreadable-only summary wordings (clause-removal flip reds both); the failed-launch inline sweep — a worker that boots, writes a ledger naming a live group, then fails attestation — leaves the group dead and the file gone (sweep deletion reds); and the launchedLedgerPaths wiring — a second environment creation leaves the first session's live marker-bearing worker and its ledger untouched (empty skip set reds).
Review round R2 — disposition summaryAll 7 threads of the historical-head review on
Every witness is verified red under its exact reverting mutation (raw-rethrow at the two arms, 503-tier removal in each ladder, inline-sweep deletion, empty skip set, unreadable-clause removal) and green on restore. Suites on the wave: standalone 135, both mapper files 107, session worker 99, acpAgent 850 — all green; build/typecheck/eslint clean. Census now: 61 resolved / 13 open, the 13 all being dev-bot deferrals open by design. |
wenshao
left a comment
There was a problem hiding this comment.
复审提交:74acebcfc04aeb43cd27852ef345fc9cdc9e411f(merge-base d0ddd020c8a64279e290538f84b152fe843ed9d4)。本轮核对上次评审 940287f96b 之后的修复、新增行为及合并后的调用关系。
上轮三项发现均已验证修复;本轮没有新增行内问题。
- 存活 writer 的记录被清扫覆盖:worker 未证明停止时不再重写账本。独立验证在初次快照后完成真实
addGroup并当场读盘确认,清扫保留新记录;模拟 worker 随后崩溃,下一次清扫仍以该组 unproven 拒绝并保留文件。旧复现依赖的“第二次读取”已被删除,因此本轮调整了注入点,并验证发布确实发生在生产清扫路径内。 - 多账本错误解除隔离:证据改为按文件归属。真实自有进程组复现中,A 不可读后被删除、B 被证明退出,隔离保持
[true];A 对应进程仍活着时没有 lift。 - 同 PGID 新记录被遗漏:最终记录替换旧时间戳并同步 remaining。原用例现在向 worker 和新进程组都发送信号,停止之后才返回
proven;不同 PGID 对照也通过。
新增修改已核查:无法定年的 ps 行仍计作存活成员,worker / group / host 三种角色的独立用例均保持 unproven/held,不误发信号或删除账本;runningMs 的所有生产读取点已检查。Standalone create/restore 保留同一可重试隔离分类,HTTP 与 ACP-RPC 两处映射均返回 503。没有新增 daemon 路由,相关调用仍使用传入的 workspace runtime。
验证结果:
npm run build、npm run typecheck、git diff --check通过。- 五个 Managed Runtime 测试文件:289 通过、1 跳过。
- ACP、Standalone service、HTTP 与 RPC 错误映射四文件:1092 通过。
- 历史复现、上轮三个问题及对照:8 通过;新增无法定年分支独立检查:3 通过。测试均关闭覆盖率,进程正常退出。
已按当前源码复核旧讨论:Route 2 的单文件与混合文件场景现已修复;close() 在已有 reaper 时可能正常返回的延期项仍存在,未把它列为本轮已修复。其余已延期的测试/结构建议不重复发帖。本轮没有验证 Windows 真机,也没有把 sys seam 的时钟/并发验证称为真实 NTP 或整机竞态验证。产品源码未修改。
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 81 passed · 0 failed · 81 total Flakiness gate: 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:81 通过 · 0 失败 · 81 总计 抖动门: Verification reportPR #13352 deep verification (round 2) —
|
| # | previous finding | severity | status at 74acebcf |
|---|---|---|---|
| 1 | F-1 — v3 Shell calls bypass the ledger entirely | Suggestion | stands — re-measured, see §5.1 |
| 2 | F-2 — killOutstanding SIGKILLs unknown groups while an unwitnessed host sweep holds them |
Observation (bounded) | stands — policy unchanged by the delta, see §5.2 |
| 3 | Correction (a) — PR body Evidence suite counts stale | Description correction | stands, drift widened — re-measured, see §4(a) |
| 4 | Correction (b) — motivating mechanism needs SIGHUP ignored, not just SIGTERM | Description correction | stands — and last round's own repro construction does not survive, see §4(b) |
| 5 | "Secondary claim 2 (quarantine + chained reaper) not independently harnessed" | Not covered | still not covered — see §7 |
| 6 | "Per-commit attribution out of reach (depth-2 shallow)" | Not covered | fixed this round — all 32 commits are reachable objects; see §7 |
| 7 | Previous round's disclosed harness fault (first mutation run reported all mutants survived) | Disclosed | superseded — this round's matrix killed 6/6 with a working positive control |
Declined/deferred rows were re-measured rather than trusted: finding 2's asymmetry was re-read in the delta (the guard's policy is untouched — prune(true) then SIGKILL everything remaining — only its table reader and clock domain changed), and finding 1 was re-checked against the head source line by line.
1. Scope
Central claim. A settled cancel of a Managed Shell call now waits until the Shell's entire process group is proven dead; base (M5a) settled on the worker's word at the group leader's exit.
Secondary claim 1. Crash cleanup no longer depends on the worker or the Managed child surviving: the host sweeps a dead worker's ledger, so orphaned groups stay attributable.
Secondary claim 2. An unprovable stop quarantines the engine (-32024), lifted by a chained reaper. Not independently A/B'd — §7.
Delta-scoped probes added this round (the guards the review rounds introduced since d18021ca): F4 (write-ahead addGroup against a real filesystem failure) and S5 (an unproven worker's ledger is never rewritten), plus mutation rows M5/M6 for the two new guards.
Budget went to re-running both real-process A/Bs against a genuine base build, the delta probes, the mutation matrix, and the affected-package gates. Two captures were reserved up front and both were produced.
The engine is still inert — re-verified, not assumed
Every severity below depends on this, so it was re-checked at the new head rather than carried forward. The input closure is not proven identical (acpAgent.ts, managed-runtime-attestation-worker.ts and managed-runtime-tool-executor.ts all changed in the delta), so the trace was redone: createManagedEngineChannelFactory still has zero production callers, and in managed-runtime-attestation-worker.ts the ledger is still created only in the boot.version !== 2 branch (line 220), which registers only registerManagedRuntimeToolRoutes; the v3 routes come from registerManagedContextRoutes in the boot.version === 2 branch (line 208), which creates no ledger. Conclusion unchanged: no shipping configuration reaches this code.
2. A/B #1 — central claim: cancel-settle evidence
Real ManagedToolExecutor → real Config → real ShellTool → real PTY → real detached process groups → real /bin/ps → real ledger file. Nothing on the unit-under-test path is stubbed. Both arms run the same harness; only ARM_ROOT differs. Liveness is judged by two independent instruments: a kernel census by pgid over the whole ps -A table, and kill(-pgid, 0). (The census is primary because ps -g PGID exits non-zero once the group leader dies — my first draft used it and read a live survivor as an empty group.)
| scenario | oracle | BASE d0ddd020 |
HEAD 74acebcf |
|---|---|---|---|
| F1 survivor ignores HUP+TERM | settle status / group members at settle / ledger names group | cancelled, 1 member alive, no ledger — 6 ms |
unknown, 1 member alive, ledger names it — 4007 ms |
| F1b survivor ignores TERM only (SIGHUP control) | same | cancelled, 0 members — 3 ms |
cancelled, 0 members — 5 ms |
| F2 plain long-run, no survivor (control) | same | cancelled, 0 members — 4 ms |
cancelled, 0 members — 4 ms |
| F3 survivor ignores HUP+TERM, self-exits inside the budget | same | cancelled, 1 member alive — 4 ms |
cancelled, 0 members — 809 ms |
F4 (head-only, NEW) addGroup's durable write really fails |
status / error text / leftover groups | n/a — mechanism absent | error, message names the real ENOTDIR on ledger.json.tmp.<pid>-<seq>, 0 leftover groups |
Assertions: head 25/25, base 20/20 (base's expectations are arm-aware and encode the intended red — see Hard rules). Witness: 01-ab-cancel-settle-base-vs-head.png; raw logs logs-ab-head.txt, logs-ab-base.txt; harness harness-ab.mjs. Reproduced twice (once for the log, once under capture) with identical outcomes.
Two flips, both re-measured at the new head. F1 is the motivating defect: base journals the call settled while a member of the Shell's process group still runs. F3 is the sharper half — base settles at 4 ms beside a survivor that would have died on its own ~800 ms later; head waits it out and settles cleanly, proving the new wait is bounded and productive, not a wedge.
Why the controls matter. F1b and F2 are identical on both arms, so head's unknown in F1 is caused by a genuinely live survivor, not by the ledger refusing to settle anything. F1b in particular pins the SIGHUP half of correction (b): a survivor that ignores only TERM dies with the group on both arms.
F4 is the mock-free version of a path the PR's own test doubles. managed-runtime-tool-executor.test.ts pins the write-failure guard with a doubled ledger whose addGroup throws new Error('ledger disk full'). F4 instead makes the durable write fail for real — the ledger's directory is replaced by a regular file, so writeFileSync of the staging name gets ENOTDIR — and confirms the guard holds against the production artifact: the call reports error, the message carries the real errno, and no process group outlives the failure. The group's whole life fits inside one 15 ms poll interval (it is SIGKILLed in the pid callback), so the observable property is the absence of leftovers rather than a witness of the kill; that limitation is stated rather than papered over.
3. A/B #2 — secondary claim: crash attributability
Real sweepWorkerLedger against real orphaned process groups and a ledger file written by the real ManagedRuntimeLedger. No sys seam — that would mock the very primitives under test. Covers all three group-identity verdicts, the exit-witness contrast, and the delta's new worker-ownership guard.
| scenario | identity | BASE | HEAD |
|---|---|---|---|
| S1 ours — dead worker, live old-enough leader | ours |
group alive, mechanism ABSENT | killed + proven (2→0 members), ledger retired |
| S2 recycled id — live young unrelated group | recycled |
group alive, mechanism ABSENT | proven with NO signal — 2→2 members, same pids, ledger retired |
| S3 leader gone, young survivor, unwitnessed | unknown |
group alive, mechanism ABSENT | held: LedgerSweepUnprovenError, 1→1 members, file retained |
| S4 same fixture, exit witnessed | unknown+witness |
group alive, mechanism ABSENT | killed + proven (1→0), ledger retired |
| S5 (NEW) worker this sweep cannot date + one resolvable and one held group | worker unknown |
n/a | LedgerSweepUnprovenError; the undated worker was not signalled; file byte-identical, still naming both groups |
Assertions: head 21/21, base 6/6. Witness: 02-sweep-ab-base-vs-head.png; raw logs logs-sweep-head.txt, logs-sweep-base.txt; harness harness-sweep-ab.mjs.
S2 is the bystander-safety property the PR names as its main tradeoff, and it holds against real processes: a recycled pgid answering for a live young group is resolved without a signal — the assertion compares the member pid list before and after, not just the count, so a kill-and-respawn would not pass. This is the failure mode that would have been catastrophic, and the one-sided start-time proof excludes it.
S3 vs S4 is a real behavioural contrast, not a duplicate: the same undatable group is held when unwitnessed and killed when the host saw the worker die.
S5 drives a guard that is new in this delta (8ea4841c46, "sweep the ledger's final truth"): a sweep that could not prove the worker stopped must not rewrite the worker's file, because any read it merged from is already stale against the worker's next durable addGroup. The fixture makes the worker undatable (record stamped 600 s in the future with no boot stamp, live decoy whose argv carries managed-runtime-worker), and gives the ledger one recycled group (resolvable without a signal, so it would leave remaining) plus one held group. The oracle is byte-level: the pre-sweep bytes equal the post-sweep bytes and both group records are still named. Had the guard been absent the file would have been rewritten down to one group — which is exactly what mutation M5 confirms (§4).
4. Mutation / vacuity matrix
Each mutant was applied to the real source, its pinning suite run, and the source restored byte-identically (git status --porcelain clean for every managed-runtime* path afterwards; per-mutant vitest output kept at tmp/mutant-M*.log).
| # | mutant | suite | expected | actual |
|---|---|---|---|---|
| M1 | cancel-settle evidence gate disabled (entry.version === 2 && → false &&) — i.e. base behaviour |
managed-runtime-tool-executor.test.ts |
killed | killed |
| M2 | recycled-id resolution removed (judgeGroupIdentity returns ours instead of unknown) |
managed-runtime-ledger.test.ts |
killed | killed |
| M3 | no-witness hold rule disabled (exitWitnessed !== true && table === undefined → false) |
managed-runtime-ledger.test.ts |
killed | killed |
| M4 | addGroup no-op — nothing attributable |
managed-runtime-ledger.test.ts |
killed | killed |
| M5 | NEW delta guard: "an unproven worker's ledger is never written" removed (if (workerProven) → if (true)) |
managed-runtime-ledger.test.ts |
killed | killed |
| M6 | NEW delta guard: parsePsElapsed day-digit cap reverted (\d{1,4}- → \d+-) |
managed-runtime-ledger.test.ts |
killed | killed |
| M7 | positive control: POLL_GROUP_EXIT_MS 50 → 55 |
managed-runtime-ledger.test.ts |
killed (corrected) | killed |
Assertions: 6 mutants killed as predicted + 3 control checks (unmutated baseline green 84/84, mutant killed, source restored) = 9/9. Witness: 03-mutation-matrix.png; logs logs-mutations.txt, logs-mutations-control.txt; harnesses run-mutations.mjs, run-control.mjs.
No survivors, so the "positive control before a survivor becomes a finding" rule does not bite. The control is nonetheless landed in the same file as M2–M6 and demonstrably turns that suite red, which rules out the "my harness never ran your suite" reading.
Disclosure — the control was mis-specified on the first attempt. I predicted POLL_GROUP_EXIT_MS 50→55 was neutral; it is not. The first matrix run therefore reported 6 pass / 1 unexpected. The cause is measured, not guessed: the sweep's proof budget is spent in poll ticks against real process groups, so a longer tick exhausts the budget and a real-process assertion observes it —
AssertionError: expected 'alive' to be 'gone'
❯ src/serve/managed-runtime-ledger.test.ts:2605 expect(processGroupLiveness(shell)).toBe('gone');
The corrected row was re-executed rather than re-scored on paper (run-control.mjs): unmutated baseline exit 0 (84/84), mutated exit 1, source restored byte-identically, harness exit 0. The first attempt's single unexpected outcome is a fault in my prediction, not in the PR, so per the Hard rules it is disclosed and superseded rather than counted as a fail against the PR — the same treatment the previous round gave its own harness fault. One detail worth a maintainer's eye: the control's red landed on different tests in the two runs (sweeps every file, removes write debris… + skips non-regular entries… first, leaves ledgers this host launched to their own sweeps second). That is expected for a tick-size mutation against real processes on a loaded runner, and the unmutated suite was green in both runs and in the full gate (3052/3052, 0 skipped), so I am not reporting head as timing-fragile — only noting that these particular assertions carry an unmeasured margin against POLL_GROUP_EXIT_MS.
M1 fails the intended assertion, not the compile. From mutant-M1.log — the failure names expected-versus-actual values on the behavioural mismatch the tests exist to catch, so the central test is not vacuous:
FAIL … > ManagedToolExecutor physical stop > makes the call unknown when the group outlives the evidence budget
AssertionError: promise resolved "{ executionStatus: 'cancelled', …(1) }" instead of rejecting
The two new delta guards fail their intended assertions too, and each failure is the exact property the guard exists for — which is what makes M5/M6 kills rather than collateral compile breaks:
M5 (unproven worker's ledger never written) : AssertionError: expected [ 401 ] to deeply equal [ 401, 402 ]
M6 (ps elapsed day-digit cap) : AssertionError: expected 38109073018720000 to be undefined
M5's [401] vs [401, 402] is a group record silently lost from the file of a worker the sweep could not prove stopped — the same property S5 asserts behaviourally at the byte level, so the harness and the suite agree on what the guard buys. M6's 38109073018720000 is an uncapped day count parsing into an absurd age instead of being rejected as undatable.
5. Findings (both non-blocking, both carried forward and re-measured)
5.1 F-1 (Suggestion) — v3 Shell calls bypass the ledger entirely. Inferred from reading; currently unreachable. Stands at 74acebcf.
In managed-runtime-tool-executor.ts the v3 branch is still tested before the ledger branch, and still returns:
if (entry.version === 3 && entry.captureSink) { return (invocation as ShellToolInvocation).execute(…, entry.captureSink); } // :2087, no setPidCallback
if (entry.toolName === ShellTool.Name && this.options.ledger) { … ledger.addGroup({ pgid: pid, … }) … } // :2098and the settle-evidence block at :2232 is still gated on entry.version === 2. A v3 Shell call would therefore neither record its group nor wait for it.
Still not a live defect: the ledger exists only in the attestation worker's boot.version !== 2 branch (managed-runtime-attestation-worker.ts:220), which registers only registerManagedRuntimeToolRoutes; the v3 routes come from registerManagedContextRoutes in the boot.version === 2 branch (:208), which creates no ledger. Re-verified by grep at the new head — the two never coincide.
Still worth a line: the coupling is invisible at the call site, and the delta widened the constructor (a new ownsAnotherSessionDir parameter — 0506242fba, "unify the executor constructor") without pinning the precedence. If M6 registers v3 routes in a ledger-bearing worker, Shell groups silently stop being attributable with no test going red. An assertion in the worker boot (ledger present ⇒ v3 routes absent), or a comment at :2087 naming the precedence as load-bearing, would pin it.
5.2 F-2 (Observation, bounded) — unknown is signalled on worker close but held on an unwitnessed host sweep. Stands at 74acebcf.
killOutstanding() runs prune(true) and then SIGKILLs everything still in the ledger, including groups judged unknown; sweepWorkerLedger() with no exit witness holds unknown and refuses to call the stop proven — verified live in S3 above. Same verdict, opposite policy.
The delta touched this method (a shared amortized table reader, and Date.now() → performance.now() for the deadline) but not the policy. It still looks defensible rather than wrong: at close the worker owns records it wrote this incarnation, and the bystander door that matters stays closed because prune(true) resolves recycled before any signal goes out — which S2 confirms behaviourally against real processes.
The residual is unchanged and narrow: it needs the recorded pgid to be recycled and the new group's leader to have died inside that same worker's lifetime, leaving only young survivors. Not constructed — labelled inferred, not measured. Recorded only so the asymmetry between the two call sites is a deliberate choice on the record.
6. Corrections to the PR description
These are corrections to the description, not requests to change code.
(a) The Evidence suite counts are still stale, and the drift has widened since the last round. Measured from junit.xml of one clean run (junit/cli.xml, junit/core.xml):
| PR body claims | previous round | this round |
|---|---|---|
| ledger 39/39 | 72 | 84 |
| executor 6/6 | 60 | 71 |
| session-worker 73/73 | 93 | 99 |
| process 7/7 | 7 ✓ | 7 ✓ |
| acpAgent 843/843 | 845 | 850 |
| core session-log 53/53 | 58 | 58 |
(body does not mention it) standalone-session-service.test.ts |
— | 135 |
Full gate this round: CLI 12 files 3052 tests / 0 failures / 0 errors / 0 skipped; core 58 / 0. Also unmentioned in the body: Session.test.ts 1157, config.test.ts 510, error-response.test.ts 73, dispatch-error.test.ts 34, attestation-worker.test.ts 29, process-env-guard.test.ts 3. Only process 7/7 still matches. A maintainer checking Test Plan step 1 against "39 tests" would reasonably suspect a wrong checkout — and this round's delta added a whole test file the plan does not list.
(b) The motivating mechanism is not what the body says — and the previous round's own repro construction does not survive either. The body describes "a Shell member that ignored SIGTERM survived its leader's exit." Two measured facts contradict the literal description:
-
ShellExecutionServiceruns under a PTY, so the leader is a session leader (psSTATSs+); when it dies the kernel sends SIGHUP to the foreground process group. A member ignoring only TERM is killed by it — F1b confirms this on both arms (0 members, settles in 3–5 ms). Reproducing the defect requires ignoring HUP as well as TERM. -
New this round, and a correction to the previous report: a shell-level
trap '' HUP TERMis not sufficient either. Bash resets trapped dispositions beforeexec'ing the last command of a subshell, so the ignore never reaches the exec'd child. Measured outside the executor (logs-probe-manual-survivor.txt,logs-probe-hup-term.txt):survivor construction group SIGTERM result (trap '' HUP TERM; exec </dev/null >/dev/null 2>&1; node -e 'setInterval…') & …yes all 3 members dead, group gone (exec </dev/null >/dev/null 2>&1; node -e 'process.on("SIGHUP",()=>{});process.on("SIGTERM",()=>{});setInterval…') & …yes leader + foreground dead, survivor alive, kill(-pgid,0)= alive at +1.0 sThe previous report's F1 row cites a survivor with STAT
S(a shell), notSl(a node process), which is consistent with its subshell bash having stayed alive rather than an exec'd node ignoring signals — but the construction as written in that report is not reproducible here, and my first two attempts this round reproduced "group gone at t+1 ms" exactly. Anyone re-running either round's F1 needs handlers installed in the surviving process itself.Also worth recording, because it silently inverts an oracle:
ps -g PGIDexits non-zero once the group leader dies, so counting its output lines reads a live survivor as an empty group. Census by pgid overps -A -o pid=,pgid=does not have that failure mode.
7. Not covered
- Per-commit attribution — now possible, and used for scoping only. Unlike the previous round (which saw 1 of 14), all 32 commits in the metadata snapshot are reachable as objects here (
git cat-file -ton each).git rev-list HEAD^1..HEAD^2still returns 1 — the shallow boundary sits at2ee42be8,d18021ca's parent, sogit merge-base --is-ancestor d18021ca HEAD^2answers NO and a naivegit diff d18021ca..HEAD^2returns 329 files / 60 359 insertions of mostly base drift. Restricting that diff to the 26 PR-touched paths gives 19 files / 3020 insertions, which is what §0's scoping used. I did not exercise each commit individually; the aggregateHEAD^1..HEADdiff plus the two A/Bs are what was verified. - Secondary claim 2 (quarantine + chained reaper lift). Still exercised only by the PR's own suites — this round that is materially more coverage than last (session-worker 99 tests, including
holds the quarantine for a ledger that vanished unjudged,holds the quarantine for good once a ledger is set aside unreadable,lifts the quarantine when the retry sweep itself proves the ledger clean,stops retrying for good when a retry ages the unreadable ledger out), and commita258dd53cdin the delta lands exactly there. I did not drive a real quarantine→lift cycle, so no independent witness exists for it. This is the largest remaining gap and it is where the delta concentrated. sweepStaleLedgersdebris renaming and the startup sweep. The delta changed staging names from.tmpto.tmp.<pid>-<serial>and the debris matcher to/\.tmp(?:\..+)?$/. I verified by reading that real ledger names are${runtimeIncarnation}.json(so a genuine ledger can never match the debris pattern) and thatledgerBasereconstruction lines up with theskipset's spelling, but I did not drive a concurrent-writer race or an aged-debris sweep.- The undatable-row path (
runningMs === undefined) and the boot-clock domain (judgedOnBootClock). Both are new in the delta (1fb61b97bf,8ea4841c46). Producing a genuinely undatablepsrow needs procps's negative-elapsed wraparound, which this container cannot manufacture, and the only other route is thesysseam — which would mock the primitives under test. So these branches are covered here only by M6 (the day-digit cap is pinned: killing it turns the suite red) and by the PR's own tests. Labelled inferred, not measured. - End-user / CLI behaviour. The engine is inert (§1), so there is no user-visible surface to drive. No tmux, headless, or
qwen serverun would exercise this code. - Windows. No Windows machine in the
node:22-bookwormcontainer. The PR itself defers real-machine verification to before M6 and labels its win32 coverage as unit doubles. - Repo-wide
npm run typecheck/ ESLint / Prettier at head. Not run. The workflow completednpm run buildat head before my clock started (tsc-based, so the head tree compiles), and my base control build compiled with 0 TS errors. No lint gate was executed, so none is claimed. - The accepted residuals the PR names — spawn-to-pid-callback window,
setsid/double-fork daemons. Not tested as claims this round. - Shape vs cause. Both A/Bs reproduce the handling of the defect against real processes; neither reproduces a model-side or workload-side trigger, because the trigger here is simply "a Shell member that outlives its leader", which the harness constructs directly.
8. Methodology
Environment: the CI verify job's node:22-bookworm container, working tree at refs/pull/13352/merge (depth 2), npm ci + npm run build already completed at head. 64 cores, 247 GB RAM, node v22.23.3, zstd absent, $RUNNER_TEMP empty in this shell.
Base control. git worktree add tmp/base-tree HEAD^1 (d0ddd020), then head's node_modules hardlinked in (cp -al, root plus all 11 per-package trees plus 11 channel trees) so the relative @qwen-code/* symlinks resolve into the base tree. Asserted before use, and quoted here because a monorepo's internal links silently defeat a naive control: readlink -f node_modules/@qwen-code/qwen-code-core from inside tmp/base-tree/packages/cli → /…/tmp/base-tree/packages/core, and acp-bridge likewise. This is a clean control because the PR leaves the dependency tree untouched — asserted first, git diff --name-only HEAD^1..HEAD -- package.json pnpm-lock.yaml pnpm-workspace.yaml 'packages/*/package.json' returned empty. Base core and cli were rebuilt from base source (NODE_OPTIONS=--max-old-space-size=6144); dist/ for the 12 workspaces outside the PR's diff was hardlink-seeded from head, and packages/cli/dist and packages/core/dist were explicitly not seeded. Final base build: 0 TS errors. Post-build assertion: base's executor dist contains 0 references to managed-runtime-ledger / waitForGroupExit / killOutstanding while head's contains 4, and base has no managed-runtime-ledger.js at all. Full log: logs-base-setup.txt.
Harnesses. harness-ab.mjs drives the compiled dist/ ManagedToolExecutor built by forWorkspace() — real Config, real ShellTool, real PTY, no test doubles anywhere on the path. It spawns real detached process groups, cancels through the public cancel(), and observes the settle status, the journal state, the group's kernel liveness by two independent instruments, and the ledger file's contents. harness-sweep-ab.mjs writes ledgers with the real ManagedRuntimeLedger.create/addGroup against dead worker pids and live decoys, then calls the real sweepWorkerLedger with no sys seam, asserting verdict, thrown error class, per-pid membership, whether bystanders were signalled, and byte-identity of the ledger file. probe-kill.mjs and probe-manual-survivor.sh are the instrumented diagnostics behind correction (b). Every assertion is a scripted comparison that can fail; expected base-arm reds are encoded as expectations, so fail counts only unexpected outcomes. The two A/B harnesses were each run twice — once for the raw log, once under verify-capture.mjs — with identical outcomes.
Gates. run-gates.sh ran all 12 changed/added CLI test files in one vitest invocation plus the core file separately, with junit output parsed for exact per-file counts (junit/cli.xml, junit/core.xml, logs-gates-cli.txt, logs-gates-core.txt). CLI: 3052 tests, 0 failures, 0 errors, 0 skipped. Core: 58 tests, 0 failures.
Raw logs live beside the harnesses in this directory: logs-ab-head.txt, logs-ab-base.txt, logs-sweep-head.txt, logs-sweep-base.txt, logs-mutations.txt, logs-mutations-control.txt, logs-gates-cli.txt, logs-gates-core.txt, logs-base-setup.txt, logs-probe-hup-term.txt, logs-probe-manual-survivor.txt, plus the full per-mutant vitest output at mutant-M1.log, mutant-M5.log, mutant-M6.log, mutant-M7.log, mutant-M7-control.log.
Cleanup. All spawned process groups were SIGKILLed by the harnesses' exit handlers; mutated sources restored byte-identically (git status --porcelain clean for every managed-runtime* path, verified inside the mutation harness); tmp/base-tree removed with git worktree remove --force.
Flakiness gate log
rounds=5 files=13 skipped=0
file packages/cli/src/acp-integration/acpAgent.test.ts: (cd packages/cli) npx --no-install vitest run ./src/acp-integration/acpAgent.test.ts
file packages/cli/src/acp-integration/session/Session.test.ts: (cd packages/cli) npx --no-install vitest run ./src/acp-integration/session/Session.test.ts
file packages/cli/src/config/config.test.ts: (cd packages/cli) npx --no-install vitest run ./src/config/config.test.ts
file packages/cli/src/serve/acp-http/dispatch-error.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/acp-http/dispatch-error.test.ts
file packages/cli/src/serve/conversations/standalone-session-service.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/conversations/standalone-session-service.test.ts
file packages/cli/src/serve/managed-runtime-attestation-worker.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/managed-runtime-attestation-worker.test.ts
file packages/cli/src/serve/managed-runtime-ledger.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/managed-runtime-ledger.test.ts
file packages/cli/src/serve/managed-runtime-session-worker.process.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/managed-runtime-session-worker.process.test.ts
file packages/cli/src/serve/managed-runtime-session-worker.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/managed-runtime-session-worker.test.ts
file packages/cli/src/serve/managed-runtime-tool-executor.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/managed-runtime-tool-executor.test.ts
file packages/cli/src/serve/process-env-guard.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/process-env-guard.test.ts
file packages/cli/src/serve/server/error-response.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/server/error-response.test.ts
file packages/core/src/config/managed-session-log.test.ts: (cd packages/core) npx --no-install vitest run ./src/config/managed-session-log.test.ts
per-file results (P=pass F=fail I=infra-exit, one letter per run):
packages/cli/src/acp-integration/acpAgent.test.ts: PP
packages/cli/src/acp-integration/session/Session.test.ts: PP
packages/cli/src/config/config.test.ts: PP
packages/cli/src/serve/acp-http/dispatch-error.test.ts: PP
packages/cli/src/serve/conversations/standalone-session-service.test.ts: PP
packages/cli/src/serve/managed-runtime-attestation-worker.test.ts: PP
packages/cli/src/serve/managed-runtime-ledger.test.ts: PP
packages/cli/src/serve/managed-runtime-session-worker.process.test.ts: PP
packages/cli/src/serve/managed-runtime-session-worker.test.ts: P
packages/cli/src/serve/managed-runtime-tool-executor.test.ts: P
packages/cli/src/serve/process-env-guard.test.ts: P
packages/cli/src/serve/server/error-response.test.ts: P
packages/core/src/config/managed-session-log.test.ts: P
verdict: timeout
summary: the 15-minute budget elapsed before two full rounds completed (1 done) — no flakiness signal either way
--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 1 · packages/cli/src/acp-integration/session/Session.test.ts: P (exit 0)
round 1 · packages/cli/src/config/config.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/acp-http/dispatch-error.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/conversations/standalone-session-service.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/managed-runtime-attestation-worker.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/managed-runtime-ledger.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/managed-runtime-session-worker.process.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/managed-runtime-session-worker.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/managed-runtime-tool-executor.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/process-env-guard.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/server/error-response.test.ts: P (exit 0)
round 1 · packages/core/src/config/managed-session-log.test.ts: P (exit 0)
round 2 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 2 · packages/cli/src/acp-integration/session/Session.test.ts: P (exit 0)
round 2 · packages/cli/src/config/config.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/acp-http/dispatch-error.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/conversations/standalone-session-service.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/managed-runtime-attestation-worker.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/managed-runtime-ledger.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/managed-runtime-session-worker.process.test.ts: P (exit 0)
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
Code reviewStatic review of 1. A foreground Shell admitted through the v3 capture route never reaches the ledger. This is the one finding I'd want answered before M6. In if (entry.version === 3 && entry.captureSink) {
return (invocation as ShellToolInvocation).execute(
entry.controller.signal, undefined, undefined,
undefined, undefined, undefined, entry.captureSink); // no setPidCallback
}
if (entry.toolName === ShellTool.Name && this.options.ledger) { /* addGroup lives only here */ }
If v3 is deliberately out of M5c's scope, that is fine — but then the claim needs narrowing and the residual belongs in the design doc's M5c section in both languages, next to the setsid and spawn-to-pid-callback residuals it already records honestly. If it is not deliberate, the capture branch needs the pid callback too. Worth settling now rather than at M6, when it stops being inert. 2. The same quarantine predicate is written three times. 3. 4. 5. A permanently unprovable-but-not-terminal group retries forever. 6. What I verified rather than assumed — these are the load-bearing claims, and they hold:
The ledger module itself is careful in the places that matter: unique per-write staging names so concurrent writers cannot splice each other's documents, undatable Mechanism (ledger lifecycle)sequenceDiagram
participant P1 as Host session worker
participant P2 as Runtime worker
participant P3 as Ledger file
participant P4 as Shell process group
participant P5 as ACP engine admission
participant P6 as Reaper
P1->>P2: spawn with ledger path in env
P2->>P3: write worker record (sync, atomic)
P2->>P4: start Shell (pid callback)
P2->>P3: addGroup before the call can settle
P1->>P2: cancel
P2->>P4: wait for whole group to be proven dead
P2-->>P1: settle only on proof, else state unknown
P1->>P3: sweep on worker exit or at close
P3-->>P1: unproven remainder
P1->>P5: quarantine (refuse new Managed sessions, 503)
P1->>P6: arm chained reaper
P6->>P3: retry until proven
P6->>P5: lift with the same reason object
Files changed (11 production files)
The remaining 15 files are 13 test files (6147 lines) and the two synchronized design docs (483 lines). TestingThis run is on the CI path ( 237 check-runs on
No red checks, so there is no failing-job log to quote. The One correction to the earlier review on this PR: Not verified, and why:
Sandboxed verification would settle the part static review cannot: Real-scenario tmux testing: N/A — CI path, and there is nothing user-visible to drive: the engine registers nothing until M6, which the inertness check above confirms independently. 中文说明代码审查(针对 1. 经 v3 capture 路由准入的前台 Shell 永远不会进入账本。 这是我希望在 M6 之前得到回答的唯一发现。 2. 同一个隔离谓词被写了三遍。 3. 4. 5. 永久无法证明、又不属于 terminal 的进程组会无限重试。 6. 我核实过而非假定的部分(这些是承重声明,且都成立):
账本模块本身在关键处很审慎:每次写入使用唯一的暂存名,使并发写者不会把彼此的文档拼接起来;无法定年的 测试: 本次运行在 CI 路径上( 对本 PR 早先审查的一处更正: 未验证项及原因:Windows 行为——未验证。win32 上 沙箱验证可以解决静态审查解决不了的部分: 真实场景 tmux 测试:N/A——CI 路径,且没有任何用户可见行为可驱动:引擎在 M6 之前不注册任何东西,上面的惰性核查也独立确认了这一点。 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 3/5 — the mechanism is sound and the inertness claim checks out, but one question about coverage I cannot answer from the diff keeps this short of an approval. Going back to what I would have written myself: the same thing, roughly. A durable per-worker record of every group, an identity proof that refuses to signal what it cannot date, and a visible engine state when a stop cannot be proven. I did not find a materially simpler path that still survives a crashed worker and a crashed child, which is the requirement here. So this is not a case of the PR missing an obvious shortcut. What I did find is a gap between the claim and the code. The description says every Shell process group the worker starts is recorded; the v3 capture route starts one that is not, and nothing in the 6147 lines of new tests drives that route with a ledger. That matters less today than it will: I confirmed independently that Everything else I read held up under suspicion rather than benefit of the doubt. The concurrency details are right in the places where being wrong would be silent and expensive: unique staging names per write, undatable My remaining reservations are the ones I'd want a human to weigh, not fix:
Not approving, not requesting changes. @wenshao — you own this roadmap and have admin here, so the call is yours; the one thing I could not resolve from the diff, the tests, or the description is whether the v3 capture path is meant to be inside M5c's guarantee. (The workflow's deterministic maintainer resolver needs 中文说明信心度:3/5 —— 机制是可靠的,惰性声明也经得起核查,但一个我无法从 diff 中回答的覆盖范围问题,让这次审查达不到批准。 回到我自己会怎么写:大致相同。每 worker 一份持久化记录、一个拒绝向无法定年的对象发信号的身份证明、以及在停止无法被证明时一个可见的引擎状态。我没有找到更简的路径还能同时扛住 worker 崩溃与子进程崩溃——而这正是此处的要求。所以这不是 PR 错过了显然的捷径。 我确实发现的是声明与代码之间的落差。描述说 worker 启动的每一个 Shell 进程组都会被记录;v3 capture 路由启动的那一个不会,而 6147 行新测试中没有任何一处带着账本走那条路由。这件事今天的影响小于将来:我独立确认了 其余部分在我持怀疑态度(而非给予信任)的阅读下都站得住。并发细节在"错了就会静默且代价高昂"的地方是对的:每次写入唯一的暂存名、无法定年的 我剩下的顾虑是希望由人来权衡、而非修改的那些:
不批准,也不请求修改。@wenshao —— 这条路线图归你,且你在此拥有 admin 权限,因此决定权在你;我唯一无法从 diff、测试和描述中解决的问题是:v3 capture 路径是否应当被纳入 M5c 的保证范围。(工作流的确定性维护者解析器需要 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Triage re-run completed without a new review.
The stage comments above were updated with the latest result. View workflow run. 上方各阶段评论已更新为最新结果。查看工作流运行。 |
qqqys
left a comment
There was a problem hiding this comment.
COMMENT — reviewed at 74acebcfc04aeb43cd27852ef345fc9cdc9e411f. Not approving. This PR has nine Criticals on record across two rounds; I verified two of them closed at this head by reading the code myself, and could not confirm the other seven inside this channel's budget. An unconfirmed blocking finding is treated as open, so the verdict is a comment rather than an approval — not a judgement that the seven are still broken.
What I confirmed fixed at this head
R2-1 [certifies-falsely] — fixed, with exactly the remedy the finding prescribed. The complaint was that the raw quarantine refusal escaping bindAndRelease was translated to the retryable managed_engine_quarantined code by only one of its two production callers, so restore() fell through its catch — not a TerminalQuarantineSignal, not a StandaloneSessionServiceError, not a CdWhilePromptActiveError — discarded the restore result and threw working_directory_compromised: HTTP 409, retryable: false, for a condition that lifts on its own at the next reaper tick. I traced the chain at head rather than trusting the fix commit's title:
standalone-session-service.tsadds'managed_engine_quarantined'toStandaloneSessionServiceErrorCode, aserviceErrormessage for it, and the predicateisManagedEngineQuarantineRefusal(error)keyed onerror.data.errorKind. At the throw site the finding named (~:3090) it now translates before the error can escape:if (isManagedEngineQuarantineRefusal(retryError)) throw serviceError('managed_engine_quarantined', sessionId, true, retryError), with the comment "Translate at the throw site, once, so every caller — create and restore alike — sees one classification." Because the result is aStandaloneSessionServiceError,restore()'s existing catch recognises it, so the raw shape no longer reaches the fallback. The creation path rethrows an already-classified error instead of rewrapping it asstandalone_creation_outcome_unknown.dispatch.tscloses the other half:toRpcErrorgains a branch mappingerr.data.errorKind === 'managed_engine_quarantined'tohttpStatus: 503, and theStandaloneSessionServiceErrorarm now maps that code to 503 ahead of the 400/404/500/409 chain. So both the raw and the translated forms answer 503 retryable, and create and restore now report the same condition identically — which is the divergence the finding was about.
R1-37 — fixed. The complaint was that parsePsElapsed range-checked only the h/m/s parts, never days and never the milliseconds it returns. At head (managed-runtime-ledger.ts:172-187) a shape regex gates the whole input first — /^(\d{1,4}-)?\d{1,2}(:\d{2}){1,2}$/u — capping the day field at four digits, with the reason recorded: procps's negative-elapsed wraparound prints astronomically large days whose row would flip every one-sided age comparison. parts.some((part) => !Number.isSafeInteger(part)) still guards h/m/s, and the returned milliseconds are now bounded by construction (≤ ~8.6e11, far inside MAX_SAFE_INTEGER), so the third gap is closed by the first fix rather than needing its own check.
What I could not confirm, and why that decides the verdict
Seven Criticals from round 1 remain unverified by me at this head:
| ID | Class | File |
|---|---|---|
| R1-1 | fails-closed | managed-runtime-ledger.ts — every ledger write staging through one fixed, process-independent path |
| R1-36 | certifies-falsely | managed-runtime-ledger.ts — addGroup mutating the in-memory map before the durable write |
| R1-5 | fails-closed | managed-runtime-session-worker.ts:559 — the catch replacing lastNamed with each failure's named groups |
| R1-6 | fails-closed | managed-runtime-session-worker.ts — judged re-created inside every reaper tick while armedFiles beside it is not |
| R1-33 | fails-closed | managed-runtime-session-worker.ts — lastNamed.length === 0 read as "an unprovable stop" |
| R1-28 | fails-closed | acpAgent.ts — the quarantine refusal added inside the shared assertManagedSessionAdmission |
| R1-29 | fails-closed | Session.ts:4188 — the branch resetting child-side activation to pending on a quarantine refusal |
These sit in a 1361-line new ledger module and a 387-line worker change, and six of the seven are fails-closed findings about reaper ticks, in-memory/durable ordering and quarantine classification — the kind where the guard that gates the chain has to be read, not inferred from a symbol name. Confirming them properly is more than the remaining budget allows, and I would rather report the gap than guess in either direction.
No reviewer has confirmed them either. The standing CHANGES_REQUESTED is pinned to a36297bf23, seven commits and roughly twelve hours behind this head. The bot's newest round, posted at 12:38 against 74acebcf, states plainly that it "did not re-verify those seven threads individually, so I cannot say whether the review is now stale", records Confidence 3/5, and holds neither a verdict nor a deferral on this head. So the round-1 threads are currently unconfirmed by anyone, which is the actual reason this cannot be approved yet — not a defect I found.
My own earlier review on this PR is COMMENTED at d18021ca58 from 2026-10-06T12:44Z. That head is long superseded and I did not carry any of its conclusions into this pass; everything above was re-read from live data at 74acebcf.
Next step: the cheapest path is a round that walks the seven threads at this head and records each as fixed or still standing with the guard it read — the fix commits since round 1 (57273cda0b round 3, 1fb61b97bf undatable process rows, 8668a5875f r5 witnesses, 74acebcfc0 round R2) suggest most were addressed, but "suggest" is not confirmation and the two I did check were both genuinely closed, so the odds favour a clean sweep. Once those seven are confirmed, the CHANGES_REQUESTED pinned to a36297bf23 can be dismissed and this is approvable. Send it back to me and I will spend the budget on the seven rather than re-deriving R2-1 and R1-37.
CI
No check is red at this head: Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox), Runtime Broker and Managed Agent MariaDB / Java 21, Hosted process fault gates / MySQL 8.4 / Java 21, Serve A/B, Live Host (macos-latest), the native boundary lanes and the Java matrix are all green. review-pr was still running and route is cancelled; neither is a gate for this change, and I did not wait on them. CI is not part of my conclusion — the gap above is code-review coverage, not a failing lane.
I ran no build, test or PR-derived code; the two closures I credit are reads of the source at this head, and the seven I do not credit are named rather than assessed.
|
Walking the seven at
On the round label: the review served as round-1-in-budget plus this gap pass; the waves after it — Thread census right now: 61 resolved / 13 open, the 13 all being dev-bot deferrals that were logged as accepted deferrals in their own rounds. The standing |
yiliang114
left a comment
There was a problem hiding this comment.
Approving the current head (74acebcf).
Pass summary: the ledger-before-settle ordering holds — the pid callback fires before execute() can settle, and an addGroup failure SIGKILLs the newborn group and rethrows instead of settling unrecorded. The settled-cancel path waits on whole-group exit evidence with a bounded 10 s budget and degrades to outcome unknown (ledger entry kept) rather than settling on the leader's word. The quarantine state machine checks out: one report per ledger path, the reaper accumulates named groups across failures and lifts only with the same reason identity, and the directory-level reaper attributes proofs per file. The -32024 admission surface is sanitized (ledger basename + group count; absolute paths and pgids stay in daemon logs), and the standalone/HTTP surfaces map it to a retryable 503.
No unledgered Shell path exists in the ledgered worker: forWorkspace never wires a capture publisher (so v3 capture refuses at admission), and v2 background Shell is rejected before spawn. The witnessed-sweep carve-out — liveness-only signalling when no process table can be read, and Windows's liveness-only shape with real-machine verification gated before M6 — matches the design document's explicit contract, so it is accepted as designed rather than a finding.
Verified locally on this head: managed-runtime-ledger.test.ts 84/84 on macOS; CI is green across the Test / Lint / Serve A/B / Desktop Shell / web-shell E2E lanes. One non-blocking hardening note inline.
qqqys
left a comment
There was a problem hiding this comment.
APPROVE — reviewed at 74acebcfc04aeb43cd27852ef345fc9cdc9e411f. This supersedes my COMMENTED review on this same head (12:49:10Z). The head has not moved and nothing in that review was wrong: it credited R2-1 and R1-37 as fixed and withheld approval because seven round-1 Criticals were unconfirmed inside budget. I have now read all seven at this head. Every one is closed, so all nine Criticals on record are verified fixed by direct reading rather than by a later round's silence.
The seven, each against the finding it answers
R1-1 [fails-closed] — fixed. The complaint was that every ledger write staged through one fixed, process-independent path (${workFile}.tmp) while more than one writer writes the same ledger, so an interleaving published one writer's bytes plus another's partial. The staging name is now unique per writer: const temporary = `${workFile}.tmp.${process.pid}-${stagingSerial++}`, with the comment naming the exact hazard ("one shared .tmp would let a loser's rename…"). The cleanup path was updated to match rather than left behind — the sweep's temporary test widened to /\.tmp(?:\..+)?$/ and ledgerBase derived by the same pattern — so suffixed temporaries are still recognised as debris and mapped back to their ledger. A fix that only changed the writer and orphaned the garbage collector would have traded corruption for a leak; it did not.
R1-36 [certifies-falsely] — fixed. addGroup now computes the post-write view first (updated = existing groups minus any record for this pgid, plus the stamped record), calls writeLedgerDocument(...), and only then this.groups.set(record.pgid, stamped). A throw from the durable write therefore leaves memory untouched, so there is no memory-only group record for the host sweep to miss — it can no longer read unproven.length === 0 from the file, rmSync the ledger and return 'proven' beside a group that is still running. This is the rule the module already stated at safeRewrite ("A rewrite whose target cannot be written must not unmake the judgement it records"), now honoured on the add path too.
R1-5 [fails-closed] — fixed. The catch at the worker's sweep site now reads lastNamed = [...new Set([...lastNamed, ...unprovenGroupsOf(error)])] — accumulate and de-duplicate, never replace — with the sibling reaper's rule restated beside it ("a later failure that names fewer groups — a transient read error names none — must not forget the ones earlier failures left outstanding"). The asymmetry between the two sites, which was the defect, is gone.
R1-6 [fails-closed] — fixed. judged is still per-pass, but it is no longer the only record of a proof: a provenFiles set is hoisted outside the tick beside armedFiles, namedByFile and sawRetired, and the onFileJudged callback writes to both judged and provenFiles. The vanished-file check skips a file when provenFiles.has(workFile) || judged.has(workFile), so a pass that proves F1 and then throws for another file no longer discards F1's proof along with the local set after F1 has already been unlinked. The fix also goes further than the finding asked: namedByFile attributes unproven groups per ledger, with the reasoning recorded ("one ledger's later proof is no evidence about another ledger's stop"), and recordNames flattens AggregateError so a partial pass contributes its names.
R1-33 [fails-closed] — fixed. The two structurally indistinguishable throw sites are now genuinely different. A read-side failure (readFileSync throwing anything but ENOENT) keeps the file in place and throws LedgerSweepUnprovenError with a message saying the stop "stays unproven", documented as transient — fd exhaustion, a failing or networked tmpdir, ENOMEM. Bytes that are not a ledger take a separate path: once past TMP_DEBRIS_AGE_MS they are renamed to .unreadable and throw a different type, LedgerSweepRetiredError, "Nothing it named can ever be proven", with a fall-through to the unproven report if the rename itself fails. The worker tracks that distinction in sawRetired and consults it at the verdict, so a transient failure can no longer masquerade as an unprovable stop and strand the quarantine for the child's lifetime.
R1-28 [fails-closed] — fixed. assertManagedSessionAdmission takes an optional engine?: SessionExecutionEngine and the quarantine refusal now lives inside if (engine === 'managed'). The callers the finding named — the transcript replay config and the fork source-copy temporary target — omit the argument, so engine is undefined and they no longer trip a refusal they could neither report nor lift. The scoping is narrow and correct: the pre-existing gates (untrusted private parent, managedShuttingDown) still apply to every caller, and only the quarantine refusal is confined to callers that actually admit managed work. Supporting it, managedEngineQuarantineReasons is a Set<Error> with per-reason add and delete, so independent quarantines lift independently and the message can name how many reasons are still awaiting proof; managedQuarantineSummary flattens AggregateError, guards every access through isUnprovenSweepReport, and reports the empty-remaining case separately rather than dropping it.
R1-29 [fails-closed] — fixed, jointly with R2-1. The activation catch in Session.ts resets to 'pending' (clearing error and promise) only when isManagedEngineQuarantineRefusal(error) holds; every other refusal still poisons. That reset was only half the finding — the damage was that the sole production caller retried once immediately and then escalated to beginTerminalQuarantine, permanently quarantining and freezing a runtime whose quarantine would have lifted seconds later. The escalation is now intercepted: standalone-session-service.ts translates the refusal at the throw site into serviceError('managed_engine_quarantined', sessionId, true, retryError) — retryable, with no quarantineRuntime, no freezeForTerminalQuarantine and no TerminalQuarantineSignal — commented as "keeping the activation retryable, not freezing the runtime that might lift seconds later." So the retry the reset buys no longer ends in a permanent freeze.
Also confirmed
R2-1 and R1-37, which I verified in the previous pass, stand: toRpcError maps both the raw errorKind === 'managed_engine_quarantined' shape and the translated StandaloneSessionServiceError code to HTTP 503 ahead of the 400/404/500/409 chain, so create and restore answer identically and retryably; and parsePsElapsed gates its input with /^(\d{1,4}-)?\d{1,2}(:\d{2}){1,2}$/u, capping the day field at four digits against procps's negative-elapsed wraparound, safe-integer checks the h/m/s parts, and bounds the returned milliseconds by construction.
The close path in managed-runtime-tool-executor.ts fails closed as well: killOutstanding() calls ledger.complete() only when remaining.length === 0, and otherwise logs the unproven pgids without certifying anything; a throw there warns rather than breaking close.
The standing CHANGES_REQUESTED is stale
It is pinned to a36297bf23, seven commits and roughly thirteen hours behind this head, and every finding it carries is now closed on the evidence above. I cannot dismiss another account's review and have not tried to; I am recording that the evidence supports dismissing it. The bot's own newest round (12:38, confidence 3/5) declined to re-verify those threads and holds no verdict on this head, so this pass is the first confirmation at 74acebcf — which is the gap that kept my previous review short of an approval.
CI
No check is red at this head. Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox), Runtime Broker and Managed Agent MariaDB / Java 21, Hosted process fault gates / MySQL 8.4 / Java 21, Serve A/B, Live Host (macos-latest), the native boundary lanes and the Java matrix are green; route is cancelled and review-pr was running, neither a gate for this change. I re-read the failure count immediately before publishing.
Scope of this approval
The verification above is targeted at the nine findings and the mechanisms they concern: the ledger's staging path, addGroup, the transient/retired discrimination and parsePsElapsed; the worker's lastNamed accumulation, reaper-tick scoping and verdict; the admission gate and its quarantine bookkeeping; the activation catch and the throw-site translation with its RPC and HTTP mapping; and the executor's close-time sweep. I did not read every line of the 1361-line ledger module, error-response.ts (+30/−12), or the ~6000 lines of new and changed tests, and I ran no build, test or PR-derived code. Under this channel's bar an unread test is not itself a Critical, and I found no Critical in what I read.
Real-environment verification, round 6 —
|
| Case | Previous head (row dropped) | This head (row kept, age unknown) |
|---|---|---|
| The ledger's own 1 s old group, stale sweep | left running, ledger deleted, proven |
held: no signal, ledger kept, unproven (quarantine stands) |
| The same, witnessed sweep | left running, ledger deleted, proven |
SIGKILLed, proven |
| A newborn unrelated process on a 1 h old record's recycled pgid, stale sweep | spared, ledger deleted, proven |
spared, held until it exits |
| The same, witnessed sweep | spared, ledger deleted, proven |
SIGKILLed |
| A sibling's live worker whose host row is undatable, stale sweep | the live worker SIGKILLed, ledger deleted | held |
Control on this head with every row datable: the own group is SIGKILLed, the newborn holder is spared on both sweeps, and the sibling is held. So the fix closes the false gone in round 5, and also a case round 5 did not test: on the previous head an undatable host row made a sibling sweep kill a live worker. One residual: on a witnessed sweep an undatable newborn holder of a recycled pgid is still SIGKILLed. That is by design ("a witnessed sweep signals it: the witness names the group the dead worker's"), and it needs both a wrapped etime, which our hosts never printed, and a pgid recycled before the witnessed sweep runs. Not blocking.
2. Your review 5437392272: all three reproduced on the previous head and fixed on this one.
- P1-1 — a live writer's record lost behind the sweep's rewrite. Real processes in a private pid + mount namespace on Linux. A fake orphan worker (argv carries the worker marker) holds a real
ManagedRuntimeLedgerin its own process;/bin/psfails, so the stale sweep cannot prove the worker stopped. After every read the sweep makes of the ledger file, the worker durably publishes one more real group withaddGroup. Then the worker is SIGKILLed and a second sweep runs withpsrestored. 3 runs each:- Previous head: the sweep reads the file twice; the group published after its merge read is gone from disk after the rewrite. The second sweep returns
proven, deletes the ledger, and that group is still alive, never signalled. - This head: one read, nothing written, nothing lost. The second sweep SIGKILLs every group; nothing is left alive.
- Previous head: the sweep reads the file twice; the group published after its merge read is gone from disk after the rewrite. The second sweep returns
- P1-2 — mixed failure lifts a never-judged ledger. The round-5 daemon scenario (macOS): one startup-sweep failure names an unreadable A and a B whose group is held
unknown; A is deleted by hand at 4 s, B's member exits at about 25 s.- Previous head: 200 at 31.8 s (round 5: 35.5 s).
- This head: 503 through 64.4 s (B's file is proven and removed at about 34 s; the reason stays terminal because A named nothing). Once the kept session closes, the next session is admitted (200 at 67.5 s), as in C6d.
- P2 — a same-pgid replacement judged against the old stamps. Same namespace rig. The worker's ledger records a group that died a minute ago on pid P. During the sweep's first
ps, the live worker starts a new Shell-like group on the same pid P (ns_last_pid) and records it; the sweep then kills the worker and proves it stopped. 3 runs each:- Previous head:
proven, ledger deleted, the new group on P alive (judgedrecycledagainst the old record). - This head: the new group is SIGKILLed,
proven. - Control with a fresh pid: both heads kill it.
- Previous head:
3. Witnesses. The five the review asked for (8668a5875f) now go red under their reverting mutants: R1-5 (R5), R1-45 (R9), R1-8 (R11), R1-12 (R12) and R1-48's sweep-proof deadline (R13). R1-48's other two deadlines (waitForGroupExit and killOutstanding; R14, R15) still survive. The code moved all three sites to performance.now(), but only the first is pinned.
The bot's review R2 fixes
- R2-5 (daemon, the P1-2 scenario's refusal text):
- Previous head: "(a ledger it could not read held the unproven stop)".
- This head: "(deadbeef-mixed-b.json: 1 process group(s) not proven stopped; a ledger it could not read held the unproven stop)". An unreadable ledger alone (C6) still reads as the clause alone.
- R2-3 (each tree's built
toRpcErrorandsendBridgeError, a retryablemanaged_engine_quarantinedservice error):- Previous head: httpStatus 409 on both surfaces.
- This head: 503 on both,
Retry-After: 5andretryable: truekept.
- R2-1 (restore path) needs a Conversations runtime whose child is a Managed host (an M6 composition), so it was not driven in a real stack; mutants T1–T3 below cover it.
- R2-4 / R2-7 are new witnesses only; mutants T8–T10 below.
Round 5's fixes still hold on this head (macOS daemon)
- R1-28: the first transcript read of an open session while quarantined is 200 with 8 events; new sessions are still 503.
- R1-11 / R1-44: "a sweep of its Runtime ledgers did not prove the stop", then "…; 1 more reason(s) awaiting proof".
- R1-5: lifts 6.5 s after the hand delete.
- R1-36: a Shell call with the ledger dir read-only fails with EACCES and leaves nothing running.
Regression on the earlier findings (Linux arm64 namespace rigs)
| Finding | This head |
|---|---|
| R2-1 | Still fixed — tick-aligned trigger, 3/3 runs: 503 through 40 s with the unrelated process on the recycled pgid untouched; lifts 3.2–3.3 s after ps returns, process alive |
| R3-1 (the reaper's own pass) | Still fixed. Lifts at 10.4 s; the unrelated process is alive in all 12 samples |
| R3-2 (Docker-like PID 1, pgid 1) | Still fixed. The sibling holds A's hostPid: 1 ledger; A's worker keeps running and A admits |
| Residual: R3-1 sibling route | Unchanged. 503 for 60 s+ over an empty ledger directory; lifts 14.4 s after the unrelated process exits |
| Residual: PID 1 whose pgid is outside its namespace | Unchanged. The sibling SIGKILLs A's live worker |
| Residual: PID-1 host never reaps (#13464) | Unchanged. 503 at 8 s and 28 s |
A/B on the new head (macOS and Linux)
Same as rounds 1–5. Base leaks the TERM+HUP-ignoring member in every crash shape. Head (macOS / Linux):
- C2 (worker killed): member SIGKILLed 78 / 177 ms later.
- C3 (Managed child killed): the worker lingers 10.2 / 10.3 s, then kills the member.
- C4 (double death): member killed 0.7 / 2.1 s after the next
POST /session. - C5 (undatable group): 503 until the member exits; 200 at 31.1 / 30.4 s.
- C6 (0-byte ledger): 200 2.2 / 3.7 s after the hand delete.
- C6b / C6d: as before (C6d: 503 for 46 s after the delete while a session keeps the child).
- C1 (cancel, member ignores TERM+HUP): still
outcome_unknownand a blocked session; the root cause is bug: POSIX Shell cancellation leaves TERM-ignoring descendants after leader exit #13441.
The merges brought main's #13168 into the Runtime tool executor (readWorkspaceContext). It only reads files and starts no process, so it is outside what M5c stops.
Tests and mutation
79 of 95 mutants killed: round 5's 79 (re-anchored) plus 16 for this round's fixes. Verdicts exclude the executor test fails the call and kills the group when the ledger cannot record it, which is flaky on macOS and red on the Linux root runner's clean tree. N10, whose only killer is that test, was re-run alone: mutant 5/5 red (expected [] to have a length of 1), clean tree 0/5, so it is a real kill. P6 (narrow COLUMNS) only bites on Linux and ran there.
- Round 5's set: 69/79 (was 64).
- Newly killed: R5, R9, R11, R12 and R13 by the new witnesses, and W9 by the R2-7 witness.
- Still surviving:
- C1, as in every round.
- The ones the author parked: R1 and R2 (R1-1), R6 and R7 (R1-5's
sawRetiredsites), R8 (R1-6). - R14 and R15: R1-48's other two deadlines.
- R21: unreachable.
- Newly surviving: R22, the create path's own translation of a raw refusal. It is now redundant: the only producer in that block,
bindAndRelease, translates first.
- This round's 16: 10 killed.
- Pinned:
- S2: P2's same-pgid adoption.
- S3: P1-2's per-file names.
- U1: keeping undatable rows.
- T1 and T3: R2-1's commit arm and the create-path passthrough.
- T4 and T5: both R2-3 ladders.
- T6: R2-5's named entries.
- T8: R2-4's inline sweep.
- T10: R2-7's skip set.
- P1-1's no-rewrite rule is pinned by the re-anchored Q2.
- Survive:
- U2: an undatable worker row judged by age anyway. It then reads as
recycled, so a live worker would be certified gone without a signal. - U5: an undatable host row no longer holds. This is the case the probe above shows the previous head SIGKILLing a live sibling worker.
- T7: the unreadable-only refusal printing "ghost.json: 0 process group(s) not proven stopped". The disposition says this wording never appears; the code is right, but no test pins it.
- S5: names from the reaper's retries are not recorded. This fails closed: a lift that could be proven stays terminal.
- T9: a failed launch keeps its skip-set entry (the inline sweep still runs).
- T2: the release arm's translation, unreachable like R21.
- U2: an undatable worker row judged by age anyway. It then reads as
- Pinned:
Suites:
- The nine affected suites pass on macOS at this head: 1381 passed, 1 skipped; core 58 passed.
- On the Linux root runner, the five Managed Runtime suites pass except the executor test above.
- CI on the head: 29 checks pass.
Not covered
- R2-1's restore path and R1-29 need an M6 composition (a Conversations runtime whose child is a Managed host); covered by unit witnesses and mutants only.
- No Windows run. By reading the code: on Windows a stale sweep never has a table, so every live worker takes the unproven path that P1-1 changed; that path now never writes.
- The P1-1 and P2 probes trigger the worker's write at a chosen point (after each sweep read; during the first
ps) instead of racing for it; the processes, files and sweep are real.
中文版本
真实环境验证,第 6 轮 — 74acebcfc0(已合入为 718ae1e6c6)
接第 5 轮(940287f96b)。新提交:
57273cda0b:你的评审 5437392272(P1-1、P1-2、P2)。1fb61b97bf:R1-37 后续——无法定年的ps行作为「年龄未知」的成员保留。8668a5875f:R1-12、R1-48、R1-45、R1-8、R1-5 的见证测试;R1-36 的见证改用 fs mock 制造写失败。74acebcfc0:机器人评审 R2(R2-1、R2-3、R2-5,以及 R2-4、R2-7、R2-8 的见证测试)。82978515cd(文档格式)和三次合并 main。
A/B 的 base 是新的合并基点 d0ddd020c8。装置与之前相同,另外为 P1-1 和 P2 加了真实进程探针。
合入后确认。 本轮进行期间,这个 PR 已于 13:30Z 以 718ae1e6c6 合入,所以这份报告不是合并门禁。合入提交里 PR 改动的 26 个文件与实测的 head 逐字节相同(git diff 74acebcfc0 718ae1e6c6 -- <这些文件> 为空),下面的结论同样适用于 main。
第 5 轮的三件事都已解决,真实链路上没有发现新缺陷:
- R1-37: 无法定年的行现在让所在组判为
unknown而不是gone。同时,兄弟清扫也不会再因为 host 行无法定年而杀掉存活的 worker,上一个 head 会这样做。 - 评审 5437392272: P1-1、P1-2、P2 都在上一个 head 上复现,在本 head 上都已修复(Linux 上用真实进程,macOS 上用 daemon)。
- 见证测试: R1-5、R1-45、R1-8、R1-12 以及 R1-48 的清扫证明截止时间现在都被钉住。变异测试:95 个杀掉 79 个。
后续事项(都只涉及测试):
- 无法定年的 worker 行与 host 行两个分支(U2、U5)。
- R1-48 的另外两处截止时间。
- 只有不可读账本时的拒绝文案(T7)。
- 作者暂缓的几项:R1-1、R1-6 和
sawRetired两处。
第 5 轮的三件事:上一个 head 对比本 head
1. R1-37 后续。 与第 5 轮相同的探针:真实存活的进程组、真实的 ps 进程表、各自构建出的清扫代码,只把其中一行的 etime 换成 procps 的回绕写法 441077234-00:18:40。
| 情形 | 上一个 head(整行丢弃) | 本 head(保留该行,年龄未知) |
|---|---|---|
| 账本自己 1 s 前的组,陈旧清扫 | 继续运行,账本被删,proven |
持住:不发信号,保留账本,unproven(隔离保持) |
| 同上,见证清扫 | 继续运行,账本被删,proven |
SIGKILL,proven |
| 1 h 前记录的 pgid 被回收,由新生的无关进程占用,陈旧清扫 | 放过,账本被删,proven |
放过,持住直到它退出 |
| 同上,见证清扫 | 放过,账本被删,proven |
SIGKILL |
| 兄弟会话的存活 worker,其 host 行无法定年,陈旧清扫 | 存活的 worker 被 SIGKILL,账本被删 | held |
本 head 上所有行都能定年的对照:自有组被 SIGKILL,新生占用者在两种清扫下都被放过,兄弟会话被持住。所以这次修复关掉了第 5 轮的假 gone,也关掉了第 5 轮没测到的一种情形:上一个 head 上,host 行无法定年会让兄弟清扫杀掉存活的 worker。残留一处:见证清扫下,回收 pgid 上无法定年的新生占用者仍会被 SIGKILL。这是设计如此(「见证清扫会发信号:见证把该组认定为已死 worker 的组」),而且需要同时满足回绕 etime(我们的主机从未打印过)和见证清扫运行前 pgid 已被回收。不阻断合并。
2. 你的评审 5437392272:三条在上一个 head 上都复现,在本 head 上都已修复。
- P1-1——清扫重写后丢掉存活 writer 的记录。 Linux 上私有 pid + mount 命名空间里的真实进程。一个伪造的孤儿 worker(argv 带 worker 标记)在自己的进程里持有真实的
ManagedRuntimeLedger;/bin/ps失败,所以陈旧清扫无法证明 worker 已停止。清扫每读一次账本文件,worker 就用addGroup持久化发布一个新的真实进程组。随后 SIGKILL worker,恢复ps,再跑第二次清扫。每边 3 次:- 上一个 head:清扫读了两次文件;在合并读之后发布的那个组,重写后从磁盘上消失。第二次清扫返回
proven并删除账本,而那个组仍然活着,从未收到信号。 - 本 head:只读一次,不写,什么都没丢。第二次清扫 SIGKILL 了所有组,没有留下存活进程。
- 上一个 head:清扫读了两次文件;在合并读之后发布的那个组,重写后从磁盘上消失。第二次清扫返回
- P1-2——混合失败把从未判定的账本一起解除。 第 5 轮的 daemon 场景(macOS):一次启动清扫失败同时点名不可读的 A,以及组被判为
unknown而持住的 B;4 s 时手工删掉 A,约 25 s 时 B 的成员退出。- 上一个 head:31.8 s 变成 200(第 5 轮是 35.5 s)。
- 本 head:直到 64.4 s 都是 503(B 的文件约 34 s 时被证明并删除;因为 A 什么都没点名,原因保持终态)。保留的会话关闭后,下一个会话被准入(67.5 s 时 200),与 C6d 相同。
- P2——同 pgid 的替换记录被按旧时间戳判定。 同一个命名空间装置。worker 的账本记着一分钟前死在 pid P 上的组。清扫第一次调用
ps时,存活的 worker 在同一个 pid P 上(ns_last_pid)新起一个类 Shell 的组并记录下来;随后清扫杀掉 worker 并证明其已停止。每边 3 次:- 上一个 head:
proven,账本被删,P 上的新组仍然活着(按旧记录被判为recycled)。 - 本 head:新组被 SIGKILL,
proven。 - 换成新 pid 的对照:两边都会杀掉它。
- 上一个 head:
3. 见证测试。 评审要求的五个(8668a5875f)现在在各自的回退变异下都会变红:R1-5(R5)、R1-45(R9)、R1-8(R11)、R1-12(R12)以及 R1-48 的清扫证明截止时间(R13)。R1-48 另外两处截止时间(waitForGroupExit 和 killOutstanding;R14、R15)仍然存活:代码三处都改成了 performance.now(),但只有第一处被钉住。
机器人评审 R2 的修复
- R2-5(daemon,P1-2 场景里的拒绝文案):
- 上一个 head:「(a ledger it could not read held the unproven stop)」。
- 本 head:「(deadbeef-mixed-b.json: 1 process group(s) not proven stopped; a ledger it could not read held the unproven stop)」。只有一份不可读账本时(C6)仍只有这一句。
- R2-3(各自构建出的
toRpcError和sendBridgeError,输入一个可重试的managed_engine_quarantined服务错误):- 上一个 head:两个出口都是 httpStatus 409。
- 本 head:两边都是 503,
Retry-After: 5和retryable: true保持不变。
- R2-1(restore 路径)需要子进程是 Managed 宿主的 Conversations runtime(M6 的组合),所以没有在真实链路上驱动;由下面的变异体 T1–T3 覆盖。
- R2-4 / R2-7 只新增了见证测试;见下面的变异体 T8–T10。
第 5 轮的修复在本 head 上仍然成立(macOS daemon)
- R1-28:隔离期间已开会话的首次转录读取返回 200,8 个事件;新会话仍是 503。
- R1-11 / R1-44:「a sweep of its Runtime ledgers did not prove the stop」,然后「…; 1 more reason(s) awaiting proof」。
- R1-5:手工删除后 6.5 s 解除。
- R1-36:账本目录只读时,Shell 调用以 EACCES 失败,没有留下运行中的进程。
回归验证此前的发现(Linux arm64 命名空间装置)
| 发现 | 本 head |
|---|---|
| R2-1 | 仍已修复——按重试对齐的触发,3/3 次:40 s 内一直 503,回收 pgid 上的无关进程没被碰;ps 恢复后 3.2–3.3 s 解除,进程存活 |
| R3-1(reaper 自己的一轮) | 仍已修复。10.4 s 解除;12 次采样中无关进程都存活 |
| R3-2(类 Docker 的 PID 1,pgid 1) | 仍已修复。兄弟会话持住 A 的 hostPid: 1 账本;A 的 worker 继续运行,A 能准入 |
| 残留:R3-1 兄弟宿主路径 | 不变。账本目录为空时 503 持续 60 s 以上;无关进程退出后 14.4 s 解除 |
| 残留:pgid 不在自身命名空间内的 PID 1 | 不变。兄弟会话 SIGKILL 了 A 存活的 worker |
| 残留:PID 1 宿主从不回收僵尸(#13464) | 不变。8 s 和 28 s 时都是 503 |
新 head 的 A/B(macOS 和 Linux)
与第 1–5 轮相同。base 在每种崩溃形态下都会泄漏忽略 TERM+HUP 的成员。head(macOS / Linux):
- C2(worker 被杀): 成员在 78 / 177 ms 后被 SIGKILL。
- C3(Managed 子进程被杀): worker 再存活 10.2 / 10.3 s,然后杀掉成员。
- C4(双重死亡): 下一次
POST /session后 0.7 / 2.1 s 成员被杀。 - C5(无法定年的组): 成员退出前一直 503;31.1 / 30.4 s 时 200。
- C6(0 字节账本): 手工删除后 2.2 / 3.7 s 变成 200。
- C6b / C6d: 同前(C6d:有会话保住子进程时,删除后 46 s 内都是 503)。
- C1(取消,成员忽略 TERM+HUP): 仍然是
outcome_unknown,会话被阻塞;根因在 bug: POSIX Shell cancellation leaves TERM-ignoring descendants after leader exit #13441。
合并带进了 main 的 #13168,给 Runtime 工具执行器加了 readWorkspaceContext。它只读文件、不启动进程,不在 M5c 要停止的范围内。
测试与变异
95 个变异体杀掉 79 个: 第 5 轮的 79 个(已重新锚定),加上针对本轮修复的 16 个。结论排除了执行器测试 fails the call and kills the group when the ledger cannot record it:它在 macOS 上不稳定,在 Linux root 运行器的干净树上直接失败。N10 唯一的杀手就是这个测试,所以单独重跑:变异体 5/5 变红(expected [] to have a length of 1),干净树 0/5,确认是真杀。P6(窄 COLUMNS)只在 Linux 上起作用,因此在 Linux 上跑。
- 第 5 轮那一组:69/79(上一轮 64)。
- 新被杀:R5、R9、R11、R12、R13 被新见证测试杀掉;W9 被 R2-7 的见证杀掉。
- 仍然存活:
- C1,每轮都是如此。
- 作者暂缓的几项:R1、R2(R1-1),R6、R7(R1-5 的
sawRetired两处),R8(R1-6)。 - R14、R15:R1-48 另外两处截止时间。
- R21:不可达。
- 新存活:R22,即 create 路径自己对原始拒绝的转换。它现在是多余的:那段代码里唯一会产生该错误的
bindAndRelease已经先转换了。
- 本轮新增 16 个:杀掉 10 个。
- 已钉住:
- S2:P2 的同 pgid 记录接纳。
- S3:P1-2 的按文件归属。
- U1:保留无法定年的行。
- T1、T3:R2-1 的 commit 分支和 create 路径直通。
- T4、T5:R2-3 的两条状态码映射。
- T6:R2-5 保留具名条目。
- T8:R2-4 的内联清扫。
- T10:R2-7 的跳过集合。
- P1-1 的「不重写」规则由重新锚定的 Q2 钉住。
- 存活:
- U2: 无法定年的 worker 行仍按年龄判断。它会被判为
recycled,于是存活的 worker 不发信号就被认定已消失。 - U5: 无法定年的 host 行不再持住。这正是上面探针里上一个 head SIGKILL 兄弟会话存活 worker 的情形。
- T7: 只有不可读账本时,拒绝文案打印成「ghost.json: 0 process group(s) not proven stopped」。处置说明说这种文案不会出现;代码是对的,但没有测试钉住。
- S5:reaper 重试中点名的组不再记录。这是失败关闭:本可以证明的解除会保持终态。
- T9:启动失败后仍保留跳过集合条目(内联清扫照常运行)。
- T2:release 分支的转换,与 R21 一样不可达。
- U2: 无法定年的 worker 行仍按年龄判断。它会被判为
- 已钉住:
测试套件:
- 本 head 上受影响的九个套件在 macOS 上全部通过:1381 通过、1 跳过;core 58 通过。
- 在 Linux root 运行器上,五个 Managed Runtime 套件除上述执行器测试外全部通过。
- head 上的 CI:29 项通过。
未覆盖
- R2-1 的 restore 路径和 R1-29 需要 M6 的组合(子进程是 Managed 宿主的 Conversations runtime);只靠单测见证和变异体覆盖。
- 没有在 Windows 上运行。读代码可知:Windows 上的陈旧清扫永远拿不到进程表,所以每个存活 worker 都会走 P1-1 改动的 unproven 路径;这条路径现在不再写文件。
- P1-1 和 P2 的探针在选定的时机触发 worker 的写入(每次清扫读之后;第一次
ps期间),而不是靠竞态撞上;进程、文件和清扫都是真实的。
Post-merge close-outEvery review thread on this PR is now replied and resolved (75/75). The 13 deferrals that were parked with recorded consent have been settled as a follow-up batch in #13630: ledger artifacts owner-only end to end ([P3] 0o700 dirs / 0o600 staging file), a Each new witness went red under its reverting mutation (dropped mode bits, defer-only close, exit-hook-sweep delete) and green on restore; suites at the follow-up head: ledger 85, session worker 101, executor 70 (+1 skip), Session 1159 — all green. |












What this PR does
Implements the M5c (physical stop) slice of #12380, landing ahead of M5b as recorded in the design's M5 split (#12737). Every Shell process group a session Runtime worker starts is now written to a worker-owned ledger file under the project's runtime temp directory, synchronously and atomically, before the call producing it can settle. A settled cancel waits until the Shell's entire process group is proven dead (M5a settled on the worker's word alone, at the group leader's exit). Crash cleanup no longer depends on the worker or the Managed child surviving: the host sweeps a dead worker's ledger at close and on every session-environment creation, so the groups a crashed worker, a crashed child, or a double death left behind are still attributable. A stop the host cannot prove moves the Managed engine into quarantine — new Managed sessions are refused with
-32024 managed_engine_quarantined— while a chained reaper keeps retrying the leftover groups until it can prove them, then lifts. As with the rest of M5, the slice registers and enables nothing: all of it is inert until M6 registers the engine.Group identity is judged from the live process table with a leader-dated, one-sided start-time proof: a true group was born before the worker wrote it down, so a live leader provably younger than the record is a recycled id and is resolved without a signal; a live id the process table cannot name is never signalled on any platform; a group nothing can date — leader gone, no old-enough survivor — is held unproven rather than dropped or killed, because that shape cannot be told from work the session itself backgrounded late. The ledger drives cancellation evidence, close-time
killOutstanding, and both host sweeps through one shared identity model.The design documents the accepted residuals honestly: the spawn-to-pid-callback window,
setsid/double-fork daemons nobody ever attributes, and Windows, which keeps its liveness-only shape and still needs real-machine verification before M6 (unit doubles only, marked as such).Design doc:
docs/design/2026-09-27-ordinary-host-managed-engine.mdanddocs/design/2026-09-27-ordinary-host-managed-engine.zh-CN.md— the "Physical stop (M5c)" section, synchronized in both languages.Why it's needed
M5a's settled cancel was the worker's word: a Shell member that ignored SIGTERM survived its leader's exit while the call was already journaled, and a worker that crashed left the Shell process groups it started orphaned where the child's registry — which knows only the groups it saw in a snapshot — could not name them. Both are correctness-class gaps for the Managed engine's physical-stop story: either one leaves untracked, possibly still-running processes the engine claims to have stopped, and neither is recoverable after the fact. The ledger makes every crash shape attributable, and the quarantine turns an unprovable stop from a logged-and-forgotten warning into a visible, self-healing engine state.
Reviewer Test Plan
How to verify
The engine is inert by design (registration is M6's), so verification is the test suites plus the readable mechanism, not end-user behavior.
cd packages/cli && npx vitest run src/serve/managed-runtime-ledger.test.ts— 39 tests: ledger lifecycle, cancel-settle evidence, both host sweeps, identity proofs (recycled-pgid, sibling-host hold with--acpand--experimental-acp, host-younger-than-worker impostor, no-table hold, leaderless hold), reaper chaining, and the POSIX real-process cases (skipIf-gated on Windows).cd packages/cli && npx vitest run src/serve/managed-runtime-tool-executor.test.ts src/serve/managed-runtime-session-worker.test.ts src/serve/managed-runtime-session-worker.process.test.ts— cancel evidence, quarantine report/lift, close sweeps, startup sweeps, and the real-process end-to-end F1/F2/F4 witnesses.cd packages/cli && npx vitest run src/acp-integration/acpAgent.test.ts && cd ../core && npx vitest run src/config/managed-session-log.test.ts— admission gate (-32024), quarantine Set by reason identity, Config propagation.docs/design/2026-09-27-ordinary-host-managed-engine.md"Physical stop (M5c)": the five acceptance criteria there map one-to-one onto the suites above.On Windows the POSIX-gated cases skip; the win32 ledger and sweep shapes are exercised by unit doubles (
sysseams), and real-machine Windows verification is explicitly deferred to before M6, as the design states.Evidence (Before & After)
N/A — nothing user-visible yet (engine registers in M6). Suite totals on this branch: ledger 39/39, executor 6/6, session-worker 73/73, process 7/7, acpAgent 843/843, core session-log 53/53;
npm run typecheckand the CLI build are clean.Tested on
Environment (optional)
Unit and real-process vitest suites run locally from the packages (no sandbox); real Shell process groups via the workspace
sleep-based fixtures.Risk & Scope
Linked Issues
Refs #12380
Refs #12737
中文说明
本 PR 做什么
实现 #12380 的 M5c(物理停止) 切片,按设计文档中 M5 的拆分记录(#12737)先于 M5b 落地。会话 Runtime worker 启动的每个 Shell 进程组现在都同步、原子地写入 worker 自己的账本文件(位于项目 runtime 临时目录下),且发生在相关调用可能结算之前。一次已结算的取消会等待 Shell 的整个进程组被证明死亡(M5a 只凭 worker 的一句话,组长退出即结算)。崩溃清理不再依赖 worker 或 Managed 子进程存活:宿主在关闭时清扫已死 worker 的账本,并在每次创建会话执行环境时清扫更早的账本,因此 worker 崩溃、子进程崩溃或二者同时死亡留下的进程组依然可被指认。宿主无法证明的停止把 Managed 引擎转入隔离——新的 Managed 会话以
-32024 managed_engine_quarantined被拒——同时链式 reaper 持续重试残留进程组直到证明为止再解除。与 M5 其余切片一样,本切片不注册也不启用任何东西:在 M6 注册引擎之前全部处于惰性状态。进程组身份由实时进程表以以组长为基准的单侧启动时间证明判定:真实的进程组必然生于 worker 落笔之前,因此存活组长可证明地比记录更年轻即说明 id 已被回收,不动杀结案;任何平台上进程表都无法命名的存活 id 绝不被发信号;无法定年的进程组——组长已死、没有足够年长的幸存成员——持为无法证明而非丢弃或强杀,因为该形状与会话自己 late-backgrounded 的工作无从分辨。账本把取消证据、关闭时的
killOutstanding与宿主两套清扫统一到同一身份模型。设计文档如实记录被接受的残留:spawn 到 pid 回调之间的窗口、没有任何人能指认的
setsid/double-fork 守护进程,以及 Windows——保持 liveness-only 形态,M6 之前仍需真机验证(目前仅单元替身,已如实标注)。设计文档:
docs/design/2026-09-27-ordinary-host-managed-engine.md与docs/design/2026-09-27-ordinary-host-managed-engine.zh-CN.md中的“Physical stop (M5c)”一节,双语同步。为什么需要
M5a 的已结算取消只是 worker 的一句答复:无视 SIGTERM 的 Shell 成员在组长退出、调用已经记录结算之后仍然存活;崩溃的 worker 把它启动的 Shell 进程组成孤儿留下,而子进程的注册表只知道它在快照中见过的进程组,根本叫不出名字。对 Managed 引擎的物理停止故事来说,二者都是正确性级别的缺口:任一种都会把引擎声称已停止、实际仍在运行且无法跟踪的进程留下,且事后无法弥补。账本让每种崩溃形态都可指认;隔离把无法证明的停止从一条记下即忘的警告变成可见、可自愈的引擎状态。
评审者测试计划
如何验证
引擎按设计是惰性的(注册归 M6),因此验证方式是测试套件加上可读的机制本身,而非最终用户行为。
cd packages/cli && npx vitest run src/serve/managed-runtime-ledger.test.ts— 39 个测试:账本生命周期、取消结算证据、宿主两套清扫、身份证明(pid 回收、含--acp与--experimental-acp的兄弟宿主保护、存活组长年轻于记录、无表全保、无组长全保)、reaper 链式调度,以及 POSIX 真实进程用例(Windows 上skipIf跳过)。cd packages/cli && npx vitest run src/serve/managed-runtime-tool-executor.test.ts src/serve/managed-runtime-session-worker.test.ts src/serve/managed-runtime-session-worker.process.test.ts— 取消证据、隔离上报/解除、关闭清扫、启动清扫,以及真实进程端到端 F1/F2/F4 见证。cd packages/cli && npx vitest run src/acp-integration/acpAgent.test.ts && cd ../core && npx vitest run src/config/managed-session-log.test.ts— 准入闸门(-32024)、按原因对象同一性维护的隔离集合、Config 传播。docs/design/2026-09-27-ordinary-host-managed-engine.md的“Physical stop (M5c)”一节:其中五条验收标准与上述套件一一对应。Windows 上 POSIX 门控用例会跳过;win32 下账本与清扫的形态由单元替身(
sysseam)演练,真机 Windows 验证按设计明确推迟至 M6 之前。证据(前后对照)
N/A——暂无任何用户可见行为(引擎在 M6 注册)。本分支套件总数:ledger 39/39、executor 6/6、session-worker 73/73、process 7/7、acpAgent 843/843、core session-log 53/53;
npm run typecheck与 CLI 构建均干净。已在
环境(可选)
单元与真实进程 vitest 套件从各包目录直接运行(无沙箱);真实 Shell 进程组用工作区内基于
sleep的测试夹具。风险与范围
关联 Issue
Refs #12380
Refs #12737