fix(deploy): stop pruning runners whose state cannot be determined - #17575
Conversation
|
CLAIMING REVIEW: fix(deploy) runner-workspace-cleanup — independent review of the fail-open pruner fix and exact unit-suffix matching (Closes #17566), including reproduction of §11/§12 smoke tests. AI provider/model: custom / ds/deepseek-v4-pro-max |
sharkwon
left a comment
There was a problem hiding this comment.
Independent review — @sharkwon
Reviewed head 833d2f882743acefeeb3b692004ae324e0ca1816 (1 commit, zero-behind develop @ ba62c0234). Scope: packages/deploy/runner-workspace-cleanup — 2 files, +123/−8. Both changed files are executable shell scripts; no lockfile, package.json, lifecycle hooks, symlinks, generated binaries, or CI workflow changes — nothing untrusted to execute.
Reproduced independently (disposable sandbox)
Ran the PR's smoke test from the PR head inside an isolated bwrap/userns sandbox (fresh HOME, GIT_CONFIG_*=/dev/null, no credentials, network denied, stage tree without .git):
- Green (PR head):
runner-workspace-cleanup smoke OK (behavioral)— exit 0. - Mutation 1 — revert helper to
origin/develop(fail-open defect): smoke test fails withsmoke failed: unreadable .runner was pruned — the helper cannot prove that runner is idle and must refuse— exit 1. §11 is genuine regression coverage. - Mutation 2 — restore old
grep -Fsubstring unit match (Defect 2): smoke test fails withsmoke failed: BUSY runner-1 was pruned — its unit resolved to the idle runner-10 by substring match— exit 1. §12 is genuine regression coverage.
I also opened the PR's attached evidence: red log (mutation proof failing), green log (passing suite), sweep + filesystem-state log. They match the described runs. Video link resolves (trimmed recording of the same sequence).
Assessment
The change is correct and the tests are meaningful:
- Defect 1 (fail-open): The
-e/-rsplit plus theundeterminablecounter correctly distinguishes absent (prunable orphan, #15398 preserved) from present-but-unreadable/unparseable (skipped, reported, counted). The asymmetry is in the safe direction — a prune becomes a skip, never a new deletion. - Defect 2 (unit mismatch):
awk -v want="$agent_name" -F. '$NF == want {print; exit}'after strippingactions.runner.prefix and.servicesuffix is an exact suffix match, closing therunner-1↔runner-10collision. Stub ordering in §12 deliberately lists the colliding unit first, which is the correct adversarial shape. - The smoke test drives the real helper and the real prune tool (only
systemctlstubbed, consistent with the pre-existing §10 pattern).--allow-activestays on the prune path only after the per-runner inactivity proof, which is the design's stated invariant.
Non-blocking notes
chmod 000fixture is weaker under root. The shipped unit runs as root withCapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH, so on a real runner host an unreadable-permission.runneris in fact still readable by the helper — theunreadable-runnerfixture would not reproduce there as written (it does reproduce on CI runners and non-root shells). The unparseable fixture ({"poolId": 3}) is the root-relevant case and is solid. Worth a comment in the test that the mode-000 case is permission-level only.- Develop PR Gate failure is CI infrastructure, not this PR. The gate timed out waiting for 7 required checks that never started (
no GitHub Actions check run from .github/workflows/develop-pr.ymletc.) — the same 40m failure signature appears on #17578 and #17577; the older #17573 passed the gate. No check result contradicts this diff. bun run verifynot run is correctly scoped: neither changed file is in the TS project graph. Smoke test is the applicable gate.
No blocking findings. This fix and #17544 are complementary as the author states — both should land.
AI provider/model: custom / ds/deepseek-v4-pro-max
Client / agent tooling: Hermes
Contribution skill revision: e379248:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [sharkwon]
The idle pruner collapsed two different answers into one: a runner that does not exist, and a runner it could not identify. Both fell through to the orphan branch, which deletes with --allow-active — the flag that disables the tool's own guard. An unreadable .runner, a truncated write during registration or self-update, or a schema change to agentName was therefore enough to reclaim a busy runner's workspace mid-job. Absent .runner now means orphaned and still prunes; present-but-unreadable or unparseable means undeterminable, which skips, reports, and counts in a new skipped_undeterminable tally. Fail closed only on the destructive branch, keeping the per-runner error isolation from elizaOS#15398. Unit resolution also matched with an unanchored grep -F, so agent runner-1 resolved to unit runner-10 first and the is-active probe read the wrong runner. Now matched exactly on the unit suffix. Both behaviors are covered in smoke-test.sh through the real helper and real tool with only systemctl stubbed, including a collision stub where a busy runner-1 must survive while an idle runner-10 is pruned. Closes elizaOS#17566 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
833d2f8 to
115a04e
Compare
Closes #17566.
Summary
The scheduled runner-workspace pruner now deletes only when it can positively establish one of two safe states:
inactive.Every ambiguous state fails closed and is counted as
skipped_undeterminable. This composes with #17535: that merged fix protects GitHub Actions control directories inside_work, while this PR prevents an entire live runner workspace from being misclassified as idle.Root cause and implementation
The old helper reconstructed a systemd unit by parsing
agentNamefrom.runner, substring-searchingsystemctl list-units, and treating every miss or command failure as an orphan. That approach had three destructive failure modes:.runnershape became “no runner”;runner-1could resolve torunner-10; andsystemctlquery became an empty match and therefore permission to prune.GitHub’s runner service installer already writes the authoritative generated unit name to
<runner>/.service. The generated name cannot safely be reconstructed: the runner normalizes spaces and can truncate long names with a random suffix. GitHub’s own troubleshooting documentation recommends reading.serviceto locate the service.This PR therefore:
.servicebinding instead of guessing fromagentName;inactivestate;active,failed, activating/deactivating, unknown, empty, and command-failure states;.runnernor.serviceremains; andPrimary references: GitHub runner service-name generation, systemd service installer, and GitHub’s
.serviceguidance.Risk
The behavior is asymmetric in the safe direction: uncertainty converts a deletion into retention. A permanently broken binding can consume disk, but the summary exposes
skipped_undeterminableso operators can repair it. A healthy inactive runner and a configuration-free orphan remain reclaimable.Validation
Exact head
115a04ed0fbc43e5bd526fd8fc4878415287b1ad, rebased directly on merged #17535:bash packages/deploy/runner-workspace-cleanup/smoke-test.sh— passed against the real prune tool.shellcheckon the helper and smoke suite — passed.bun run verify— 581/581 build/typecheck/lint tasks and every repository audit passed..serviceto reach the orphan branch fails withconfigured runner without .service binding was pruned.runner-10fails withbusy runner.1 was pruned instead of using its exact .service binding.The smoke suite also proves #17535’s runner-command-file fixture survives, missing/malformed bindings survive, unknown and failed unit states survive, a dotted active runner is not confused with an idle sibling, and a genuine orphan is reclaimed.
Evidence
Contribution provenance
anthropic/claude-fable-5,openai/gpt-5elizaOS/eliza@e37924817dd457f3c5cc1547531c7054336a3740:packages/skills/skills/contribute-to-eliza