fix(triage): make the build-process guard diagnosable and zombie-aware - #7858
Conversation
The first real /verify run to reach the agent step (job 30267953352) was
stopped by the guard I added to stop detached lifecycle children from
outliving their step:
::error::Processes owned by the build user survived; refusing to start
the agent.
That is all it printed. No pid, no state, no command line — so a genuine
threat and a harmless leftover were indistinguishable, including to the
person who wrote it. The control had no observability of its own.
Two changes, both in the verify and tmux lanes:
- name the survivors. The error now lists each one as pid, state and
command line, so the next occurrence can actually be diagnosed.
- disregard zombies. A zombie has already exited and released everything
except its exit status: it cannot re-plant an artifact or touch the
agent's inputs, and it cannot be killed either — so counting one means
the check can never clear, no matter how many times SIGKILL is sent.
The container runs no init to reap orphans, so they are expected here.
The platform difference is the reason the old form could hang: Linux
pgrep reports defunct processes ("Defunct processes are reported." —
pgrep(1), procps-ng), while macOS pgrep does not list them at all
(verified directly: ps shows our zombie, pgrep does not). So this failure
mode was unreachable in local replay and only appears on the runner.
That same difference shapes the tests. The behavioural arm constructs a
real zombie, confirms it survives SIGKILL, and shows the state filter
drops it — but it cannot discriminate the old implementation from the new
one on macOS, because pgrep never saw the zombie there in the first place.
So the portable guarantee is asserted structurally: the filter must read
process state and exclude Z, and must not be a bare pgrep. Mutation-
verified 3/3 — removing the state filter, dropping the survivor names, or
reverting the message each turn one test red.
Also gives both proxy-watchdog tests an explicit timeout. They stream 20
chunks at 200 ms — 4 s before the stall arm even starts — so they cannot
fit vitest's 5 s default and were timing out on main.
|
Thanks for the PR! Template looks good ✓ Problem: observed, not theoretical. The guard actually fired on a live Direction: aligned. This is a follow-up to the project's own guard (#7710 / #7753) and makes that control diagnosable without weakening it — a live process still fails the step closed; only already-dead zombies are disregarded, which is sound (a zombie holds no resources and executes no code). No CHANGELOG signal expected for CI-infra hardening; the area is clearly in scope. Size: not applicable — the change is confined to Approach: the scope feels right. Two tightly-coupled edits (name the survivors, drop zombies) applied symmetrically to both the Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题:已观测到,非理论性。守卫在一次真实 方向:对齐。这是对项目自身守卫(#7710 / #7753)的后续完善,让该控制可诊断而不削弱它——活进程依然 fail-closed;只忽略已经死亡的僵尸,这在语义上成立(僵尸不持有资源、不执行代码)。CI 基础设施加固不期望有 CHANGELOG 信号;该领域显然在范围内。 规模:不适用——改动仅限于 方案:范围合理。两处紧密耦合的编辑(列出存活者、排除僵尸)对称地应用于 进入代码审查 🔍 — Qwen Code · qwen3.8-max-preview Reviewed at |
Code reviewMy independent take before reading the diff: name the survivors (pid + state + args) so the error is diagnosable, and filter zombies out of the "live" set by reading process state rather than trusting The implementation is correct. The test split is the strongest part. The structural arm (filter must inspect Non-blocking note carried over from Stage 1: the two proxy-watchdog timeout bumps to 30 s are test-only flakiness fixes slightly outside the stated scope, but they keep the suite green and live in the same file — fine to keep. Test evidence (PR's own CI)This is an unattended CI run, so I'm reading the PR's own CI signal rather than executing anything. At review time the unit suite is still running on Linux; macOS/Windows and the integration/sandbox lanes are skipped for this commit. The author reports 76/76 in Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Real-scenario testingN/A for tmux — this is CI-infrastructure (the sandbox lanes' process guard), not a TUI surface, and an unattended CI run never drives live behaviour anyway. The behavioural claim that matters (Linux 中文说明代码审查我在读 diff 之前的独立思路:列出存活者(pid + 状态 + args)让错误可诊断,并通过读取进程状态(而非信任 实现是正确的。 测试的拆分是最强的部分。结构组(过滤器必须检查 从 Stage 1 延续的非阻塞注记:两个 proxy-watchdog 超时提到 30 秒是纯测试的防抖修复,略微超出声明范围,但能让套件变绿且在同一文件内——保留无妨。 测试证据(PR 自身 CI)这是无人值守的 CI 运行,因此我读取的是 PR 自身的 CI 信号而非执行任何代码。审查时单元测试套件仍在 Linux 上运行;macOS/Windows 与集成/沙箱车道对本提交被跳过。作者报告 (CI 表格见上方标记区域。) 真实场景测试tmux 不适用——这是 CI 基础设施(沙箱车道的进程守卫),不是 TUI 界面,且无人值守 CI 运行本就不会驱动真实行为。真正重要的行为性论断(Linux — Qwen Code · qwen3.8-max-preview Reviewed at |
|
Confidence: 5/5 — clean across every stage; this is a tight, well-evidenced fix to the project's own guard and I'd merge it without hesitation. Stepping back: this is exactly the kind of follow-up the gate wants to see. The author shipped a security control in #7710, watched it fail uselessly on a real run, and came back to make it diagnosable and to stop it hanging on something that is, by OS semantics, already dead. The change doesn't weaken the guard — it removes the two ways the guard could fail without value: refusing without evidence, and refusing over a zombie that can never be killed and never executes anything. My independent proposal matched the PR almost line-for-line, which is rare and a good sign. The craft shows. The structural-vs-behavioural test split, with an honest explanation of why the behavioural arm can't discriminate old from new on macOS, is the right call — and the mutation-testing note (an early test asserted the helper's name and a gutted-body mutation walked right through) is the kind of scar tissue that makes a suite trustworthy. The diff is minimal: two coupled edits applied symmetrically to both lanes, plus the tests that pin them. The only thing outside the stated scope is two test-timeout bumps, which are harmless and keep the suite green. The one thing I can't verify from here is the live Linux behaviour itself — that needs a CI is still running on this commit, so approval is deferred until it lands green on 中文说明置信度:5/5 —— 每个阶段都干净;这是对项目自身守卫的一次紧凑、有据可依的修复,我会毫不犹豫地合并。 退一步看:这正是门禁希望看到的后续。作者在 #7710 中交付了一个安全控制,看着它在一次真实运行中无用地失败,然后回来让它可诊断、并让它不再为按操作系统语义已经死亡的东西而卡住。该改动没有削弱守卫——它消除的是守卫无价值地失败的两种方式:无证据地拒绝,以及为一个永远杀不掉、也永不执行任何东西的僵尸而拒绝。我的独立方案与 PR 几乎逐行吻合,这很难得,也是个好信号。 工艺可见。结构组与行为组的测试拆分,并诚实解释为何行为组在 macOS 上无法区分新旧实现——这是正确的选择;变异测试的注记(早期测试只断言辅助函数的名字,一个掏空函数体的变异径直穿过)正是让套件可信的那种伤疤组织。diff 是最小的:两处耦合编辑对称应用于两条车道,加上钉住它们的测试。唯一超出声明范围的是两个测试超时调整,无害且能让套件变绿。 我在此无法验证的唯一一点是 Linux 行为本身——那需要一次跑到 runner 上 agent 步骤的 本提交的 CI 仍在运行,因此批准推迟到它在 — Qwen Code · qwen3.8-max-preview Reviewed at |
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
|
@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 冲突,直到移除标签或达到轮次上限。移除 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
中文说明
未发现问题。LGTM!✅
— qwen3.7-max via Qwen Code /review
Local verification on Linux — the
|
| scenario | base (main) | this PR | PR + one-line fix |
|---|---|---|---|
zero node processes (normal success) |
pass | silent exit 1 | pass |
| zombie only (the CI failure) | fail after 3 retries, bare error | pass | pass |
| straggler killed by the sweep | pass | silent exit 1 | pass |
| live process survives SIGKILL | fail, bare error | fail, survivors named | fail, survivors named |
bash -x pinpoint: the last trace line is + survivors=, then death; GUARD_PASSED never prints.
Fix (verified in all four scenarios, apply to both lanes):
survivors="$(live_build_processes)" || trueThe assignment still captures the (filtered) output; the || true only absorbs the no-match status. Equivalent alternative: || true at the end of the pipeline inside the function. Worth a test in the suite's own extraction style: run the extracted block under set -euo pipefail against a user with zero processes and assert it reaches the far side.
Why nothing caught it: the new tests are structural for this block (they grep the YAML); the only live CI run to reach this step had a zombie; and local replay evidently never combined -eo pipefail with an empty process set.
[Low] The verify lane's error message is unpinned
The tmux-lane parity test pins the full new message; nothing pins the verify lane's. Reverting only the verify lane's message (M3′ below) leaves the suite green at 76/76. The build-process-guard suite already loops ['verify', 'tmux-testing'] for the other two properties — the message assertion could join it.
Verdict
The diagnosis is exactly right and completely verified here — this is the Linux confirmation the test plan asked for. But as written the guard trades "hangs on a zombie" for "silently fails every clean run". Recommend applying the one-line || true to both lanes before merge; with it, all matrix cells behave correctly on this box, fail-closed included.
中文版
Linux 本地验证 —— 补上 ⚠️ 那一格
结论先行: PR 正文的每一项声明都在 Linux 上干净复现——平台差异诊断、CI 失败复现、76/76、watchdog 超时修复、3/3 变异验证。但把守卫块原文逐字提取、以 step 的真实方式运行(set -euo pipefail)后,发现一个结构化测试看不到的 Critical 回归:**零残留的成功路径现在会静默失败,守卫只有在恰好存在僵尸进程时才能通过。**一行修复已验证,见下;应用后所有场景行为正确。
环境:Linux 6.12.63(Debian)、procps-ng 4.0.4(node:22-bookworm 内为 procps 4.0.2,行为一致),为运行创建了真实的 node 用户;守卫块分别从 origin/main 与本 PR 的 YAML 中逐字提取,以 bash --noprofile --norc + step 自身的 set -euo pipefail 执行(step 第 1 行;job 也设置了 shell: bash)。
已确认 —— PR 的声明全部在 Linux 上复现
- 平台差异——用真实的
node属主僵尸(root 父进程 fork,子进程 setuid 后退出,父进程不回收):Linuxpgrep -u node列出它且 exit 0——旧探测会被锁死;kill -9无法移除(状态保持Z);新的awk '$2 !~ /^Z/'过滤器将其丢弃。(见上方截图 1) - 忠实复现 job 30267953352——base 守卫面对仅有僵尸的场景,烧完 3 次重试后在 3.1 秒时以那行光秃秃的
::error::Processes owned by the build user survived; refusing to start the agent.失败;PR 守卫立即放行。 - fail-closed 保持且可诊断——用真正在 SIGKILL 下存活的活进程(以无杀权限用户运行守卫)测试,PR 守卫仍 exit 1,并列出幸存者:
surviving process: <pid> S sleep 600。 - 收窄范围恰好是
Z*——向过滤器喂入合成状态矩阵:S/R/D/T/I全部仍被计入,仅Z/Zs/Z+被丢弃。 - 测试——PR head 76/76,其中行为组僵尸测试在这里是可判别地运行的(macOS 做不到);合并树 82/82(
origin/main+ PR;feat(triage): add revert-pattern high-risk path detection #7414/fix(triage): retry a transient npm ci before blaming the PR for it #7884 也改过这两个文件,试合并干净)。 - watchdog 超时声明属实——在 merge-base 上两条 proxy-watchdog 测试都以
Test timed out in 5000ms失败;main 的套件今天在这台机器上本来就是红的,本 PR 顺带修好了它。 - 变异——M1(状态过滤退回裸
pgrep)、M2(删除幸存者名单)、M3(回退错误消息,tmux lane)各使恰好一条测试变红,与声明一致。 - Lint——yamllint exit 0;两个提取出的 agent step 通过
bash -n与shellcheck -S warning;qwen-triage.yml的 prettier 告警在 main 上即存在(均为未触碰的行);actionlint 本地不可用,未复验。
[Critical] 成功路径现在会静默失败 —— 守卫只有存在僵尸时才能通过
ps -o pid=,stat=,args= -u node 在该用户零进程时退出码为 1(procps 长期行为,已在本机验证)。step 运行于 set -euo pipefail 之下,因此在
survivors="$(live_build_processes)"中:ps 的 exit 1 经 pipefail 越过 awk,命令替换返回 1,而赋值语句的状态就是替换的状态,set -e 直接杀死 step——没有任何输出。
恰恰是应当通过的两条路径以零 node 进程收尾:本来就无残留、以及残留被成功 SIGKILL。它们现在都静默 exit 1——比本 PR 要修的那个失败更难诊断。僵尸路径反而不受影响(ps 匹配到僵尸 → exit 0 → awk 清空字符串 → 守卫通过)。讽刺之处:**按现在的写法,只有僵尸才能让 agent 启动。**循环行 [ -n "$(live_build_processes)" ] || break 是安全的([ ] 参数中替换的状态被丢弃),踩雷的只有那句裸赋值。
完整矩阵(守卫块原文、GitHub 同款 shell 选项)见上方英文表格与截图 2。bash -x 定位:最后一条 trace 是 + survivors=,随即死亡,GUARD_PASSED 从未打印。
修复(四场景均已验证,两条 lane 都要改):
survivors="$(live_build_processes)" || true赋值仍会捕获(过滤后的)输出,|| true 只吸收零匹配的退出码。等价替代:在函数内管道末尾加 || true。建议按套件自己的提取风格补一条测试:以 set -euo pipefail 对零进程用户运行提取出的块,断言能走到另一侧。
为何无人发现:新测试对这段块是结构化的(grep YAML);唯一跑到该 step 的 CI 运行恰好有僵尸;本地回放显然从未把 -eo pipefail 与空进程集组合过。
[Low] verify lane 的错误消息未被锁定
tmux lane 的 parity 测试锁定了完整新消息;verify lane 的没有任何断言。只回退 verify lane 的消息(截图 3 中的 M3′)套件仍 76/76 全绿。build-process-guard 套件已对另外两个属性循环 ['verify', 'tmux-testing']——消息断言可以加入其中。
结论
诊断完全正确,并已在此获得测试方案所求的 Linux 确认。但按当前写法,守卫用"僵尸锁死"换来了"每次干净运行都静默失败"。建议合并前对两条 lane 应用那行 || true;应用后本机上矩阵所有单元格行为正确,fail-closed 完整保留。
|
🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下: Review feedback addressed[Critical] Success path dies silently under
|
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
中文说明
— qwen3.7-max via Qwen Code /review
|
🔀 Base updated: red check(s) [web-shell E2E Smoke (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [web-shell E2E Smoke (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
中文说明
— qwen3.7-max via Qwen Code /review
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: No action takenThe only feedback this round is two identical automated-reviewer comments downgrading from Approve to Comment because the web-shell E2E Smoke (ubuntu-latest, Node 22.x) CI check is failing. This PR changes exactly two files:
Neither file is related to the web-shell package or its E2E smoke test. The failing check is unrelated to this PR's changes and requires no code fix here. There are no inline comments, no issue-level comments, and no 中文说明未采取任何操作本轮唯一的反馈是两条相同的自动审查评论,因 web-shell E2E Smoke (ubuntu-latest, Node 22.x) CI 检查失败而从批准降级为评论。 本 PR 仅修改了两个文件:
这两个文件均与 web-shell 包及其 E2E 冒烟测试无关。失败的检查与本 PR 的更改无关,无需在此进行代码修复。 没有行内评论、没有 issue 级别的评论、也没有 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
|
🔀 Base updated: red check(s) [web-shell E2E Smoke (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [web-shell E2E Smoke (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
|
Qwen Code review did not complete successfully. Run review failed. See workflow logs for details. A transient error is retried automatically; if you are seeing this, retry with |
1 similar comment
|
Qwen Code review did not complete successfully. Run review failed. See workflow logs for details. A transient error is retried automatically; if you are seeing this, retry with |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: No actionable feedback this roundThe feedback for this round contains no inline comments and no reviewer findings. The only entries are:
The
No code changes were made this round. 中文说明本轮无可操作的反馈本轮反馈中没有行内评论,也没有审查者的发现。唯一的内容是:
本轮未做任何代码变更。 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
中文说明
未发现问题。LGTM!✅
— qwen3.7-max via Qwen Code /review
doudouOUC
left a comment
There was a problem hiding this comment.
Full review at f03142acb8de675376fd05c570557f42ee49db78 with local verification (worktree checkout, npm run test:scripts — 781 passed including 85/85 in the touched test file, with the real-zombie behavioural arm passing on macOS). Approving.
Verified against the code at HEAD:
- The one Critical from the review rounds — the bare
survivors=assignment dying underset -euo pipefailwhenps -u nodematches nothing — is fixed with|| trueand pinned by a dedicated structural test. Both call sites oflive_build_processesare exit-status-safe: the loop check discards the substitution status inside[ -n ... ] || break, and the assignment is guarded. - The guard stays fail-closed. A process can only influence the
argscolumn of thepsoutput, never its ownpid/statfields — argv tricks (embedded newlines) can only add noise lines that over-count survivors, never hide a live process or fake aZstate. The exclusion covers exactly the kernel-reported zombie state, which holds no resources, executes nothing, and cannot be killed — so counting one made the check permanently unclearable, as the linked run showed. ps -u nodematches the oldpgrep -u nodeselection semantics (effective UID), the step runs as root so the guard shell never self-matches, both lanes carry identical hunks, and no barepgrep -u noderemains anywhere in the workflow.- The test design is honest about the platform limit: the structural arm (function body must read
stat=and exclude/^Z/, must not be barepgrep) is what discriminates old from new on macOS, and it survives the name-only-mutation trap recorded in the comments. The behavioural arm degrades gracefully when a zombie cannot be constructed. - CI is green on this commit (main unit suite, web-shell E2E that had blocked two earlier bot approvals, review-pr), and there are no unresolved review threads.
The two vitest timeout bumps for the proxy-watchdog tests are justified drive-bys — those tests were exceeding the 5 s default on main and pass with the new budget.
中文说明
在 f03142ac 上完成全面审查并本地验证(worktree 检出、npm run test:scripts 全套 781 通过,其中目标文件 85/85,真实僵尸行为测试在 macOS 上通过)。批准。
已对照 HEAD 代码核实:评审轮次中唯一的 Critical(零进程时 ps -u node exit 1、set -euo pipefail 下裸赋值静默失败)已用 || true 修复并有专门测试锁定;live_build_processes 两处调用点退出码均安全。守卫保持 fail-closed——进程只能影响 ps 输出的 args 列,argv 技巧只会多计存活者而不可能隐藏活进程或伪装 Z 态;被排除的僵尸态不持有资源、不执行代码、不可被杀,计入它意味着检查永远无法通过(如关联运行所示)。ps -u node 与旧 pgrep -u node 选择语义一致(effective UID),step 以 root 运行不会自匹配,两条 lane 改动完全一致且 workflow 无残留裸 pgrep。测试对平台差异的处理诚实:结构断言(读取 stat=、排除 /^Z/、非裸 pgrep)在 macOS 上承担区分新旧实现的职责,且规避了注释中记录的"保名掏体"变异陷阱。该提交 CI 全绿,无未解决 review threads。
两处 vitest 超时上调是有据的顺手修复——这两个测试在 main 上就超过 5 秒默认值。
|
Released in v0.21.1. |



What this PR does
Makes the sandbox lanes' build-process guard diagnosable, and stops it hanging on processes that cannot be killed.
The first live
/verifyrun to reach the agent step (job 30267953352) was stopped by that guard — the fail-closed check added in #7710 to stop a detached lifecycle child from outliving its step. It printed exactly this and nothing else:No pid, no state, no command line. A genuine threat and a harmless leftover were indistinguishable — including to me, who wrote it. The control had no observability of its own, which is the thing it was supposed to provide about the build.
Two changes, applied to both the
verifyandtmux-testinglanes:SIGKILLis sent. The container runs no init to reap orphans, so zombies are expected there.Why it's needed
The guard is load-bearing: #7710 added it because a one-shot sweep racing a live process is not a control at all. This PR does not weaken it — a live process still fails the step closed. It removes the two ways it could fail uselessly: by refusing without evidence, and by refusing over something that is already dead.
Reviewer Test Plan
How to verify
The platform difference is the whole story here, and it explains why local replay never caught this:
pgrepreports defunct processes —pgrep(1)(procps-ng) states plainly: "Defunct processes are reported."pgrepdoes not list them at all. Verified directly: with a real zombie,ps -o pid= -p $Zreturns 1 line whilepgrep -u $(id -un)returns none.So the old
pgrep -u nodeform could latch onto a zombie on the runner and never clear, while every local replay of that same block passed.That shapes the tests, and is worth stating plainly rather than hiding:
fork()with a parent that never reaps, confirmskill -9does not remove it (state=ZN, still visible tops), and shows the state filter drops it (unfiltered=1,filtered=0). This proves the OS property the exclusion rests on.Z, and is not a barepgrep. This is the arm that holds on every platform, because the behavioural one cannot discriminate the old implementation from the new one on macOS:pgrepnever saw the zombie there to begin with.Mutation-verified 3/3: removing the state filter (back to bare
pgrep), dropping the survivor names, and reverting the error message each turn exactly one test red. The first of those took three attempts to catch — the initial test asserted the helper's name, which a mutation that gutted the body while keeping the name walked straight through. That is recorded in the test's comments.76/76 in
scripts/tests/qwen-triage-workflow.test.js;actionlintexit 0; yamllint clean; both extracted agent steps passbash -nandshellcheck -S warning.Evidence (Before & After)
A real zombie, before and after the filter:
Tested on
Linux: the behaviour this fixes is Linux-specific and cannot be reproduced on macOS (see above), so the structural assertion is what covers it here. Confirmation needs one
/verifyrun that reaches the agent step on a runner.Risk & Scope
Clean stale agent state(gitdubious ownership— 2 of 7 machines observed passing); the maintainers are handling that separately. The remaining feat(triage): add sandboxed /verify deep-verification lane #7710 follow-ups (script extraction, process-namespace isolation, moving the model client outside the agent's namespace) are unchanged.Linked Issues
Follow-up to #7710 and #7753. No issues closed.
中文说明
本 PR 做了什么
让沙箱车道的构建进程守卫可诊断,并避免它卡在杀不掉的进程上。
首次真正跑到 agent 步骤的
/verify运行(job 30267953352)被该守卫拦下——那是 #7710 中为防止 detached 生命周期子进程存活到步骤之后而加的 fail-closed 检查。它只输出了一行:Processes owned by the build user survived; refusing to start the agent.没有 pid、没有状态、没有命令行。真实威胁与无害残留无法区分——包括对写下它的我而言。这个控制自身没有可观测性,而可观测性正是它本该提供的东西。
两处改动,verify 与 tmux 两条车道同时应用:列出存活进程(pid、状态、命令行),以及排除僵尸进程(僵尸已退出并释放除退出码外的一切,无法重新预埋产物或触碰 agent 输入;而且它杀不掉,因此计入它意味着该检查永远无法通过。容器内没有 init 回收孤儿进程,僵尸是预期内的)。
为什么需要
守卫本身是有效的:#7710 加它是因为"与活进程赛跑的一次性清扫根本不算控制"。本 PR 不削弱它——活进程依然 fail-closed。它消除的是两种"无用地失败"的方式:无证据地拒绝,以及为已经死掉的东西而拒绝。
评审验证方案
平台差异是本问题的关键,也解释了为何本地回放从未发现它:Linux 的
pgrep会报告 defunct 进程(pgrep(1)procps-ng 原文:"Defunct processes are reported."),而 macOS 的pgrep完全不列出它们(已直接验证:对真实僵尸,ps -o pid= -p $Z返回 1 行,pgrep -u $(id -un)返回 0 行)。因此旧的pgrep -u node写法可能在 runner 上卡住僵尸而永不放行,而同一段代码的每次本地回放都通过。这也决定了测试的形态,且值得明说而非隐藏:行为组构造真实僵尸(
fork()后父进程不回收),确认kill -9无法移除(state=ZN,ps仍可见),并显示状态过滤将其丢弃(unfiltered=1、filtered=0);结构组断言过滤器必须读取进程状态并排除Z、且不得是裸pgrep——这一组才是跨平台成立的,因为行为组无法在 macOS 上区分新旧实现(那里pgrep根本就看不到僵尸)。3/3 变异验证:去掉状态过滤(退回裸
pgrep)、去掉存活进程名单、还原错误消息,各使恰好一条测试变红。其中第一条尝试了三次才捕获——最初的测试只断言辅助函数的名字,而一个保留名字、掏空函数体的变异径直穿过了该检查;这一点已写入测试注释。76/76 通过;
actionlint退出 0;yamllint 干净;两个抽取出的 agent 步骤通过bash -n与shellcheck -S warning。测试平台
macOS ✅;Windows N/A;Linux⚠️ ——本次修复针对的行为是 Linux 特有且无法在 macOS 复现(见上),因此由结构断言覆盖;最终确认需要一次真正跑到 agent 步骤的
/verify运行。风险与范围
Clean stale agent state失败的 runner 侧配置不一致(gitdubious ownership——已观察到 7 台中 2 台可通过),维护者正在单独处理;feat(triage): add sandboxed /verify deep-verification lane #7710 其余后续项(脚本抽取、构建阶段进程命名空间隔离、模型客户端移出 agent 命名空间)不受影响。关联 Issue
#7710 与 #7753 的后续。不关闭任何 issue。