fix(env-guard): stop truncating a fleet-shared file in place - #1810
Conversation
The agent HOME is a ReadWriteMany CephFS volume mounted by every agent pod. buildEnvGuardSetupShell installed the guard with `> "$GUARD_DIR/paperclip-env-guard.mjs"`, which is O_TRUNC, so every pod in the fleet issued setattr(size=0) against the SAME inode on startup. One of those truncates wedged in the MDS (truncate_pending set with nothing driving it). Every later truncate then queued behind it permanently: 68+ ops stuck at "acquired locks" for hours across 8 nodes, open() on the file blocking in D-state, and the Bash PreToolUse hook hanging fleet-wide. Neither an MDS failover (the standby replays the same stuck state) nor a scrub clears it, and the poisoned dentry cannot even be renamed over, because unlinking the target needs the very locks the stuck truncate holds. Fix both halves: - Never truncate a shared file. Each script is written to a pod-unique temp and rename(2)d into place, and only when the target is absent (`test -f` stats the dentry, it never opens it). A given inode is written exactly once, truncated never. - Content-address the guard filename. A guard change now lands as a new file instead of an in-place rewrite of the shared one, which also routes around an already-poisoned inode rather than blocking on it. settings.json gets the same treatment: it is written via temp + renameSync rather than an in-place writeFileSync (same shared-volume hazard), and stale hook entries pointing at a previous guard file are pruned so pods stop invoking a wedged path. Regression tests assert no redirect targets a non-temp path, that every install is create-if-absent landing via rename, that the guard name is content-addressed, and that a stale guard hook is pruned. All three fail against the previous implementation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Hey @kkroo! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
|
@ally please review this head: b2e9946 Root cause: the claude-k8s adapter installed its hook with Fix: never truncate a shared file (create-if-absent, pod-unique temp + rename), and content-address the guard filename so a change lands as a new inode instead of rewriting the shared one. Please pay particular attention to:
|
The vendored-adapter CI gate recomputes a sha256 manifest over every tracked file in vendor/paperclip-adapter-claude-k8s (excluding LICENSE and PROVENANCE.md) and fails when it does not match the hash recorded in PROVENANCE.md. Editing env-guard.ts changed it. Refresh the recorded hash and add the change to the Local modifications table, which is the record of how far the tree has diverged from upstream. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Hey @kkroo! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
|
@ally the PR body blocker you flagged is resolved — all six required sections (Thinking Path, Linked Issues, What Changed, Verification, Risks, Model Used) and the dedup checkbox are now present. Please review this head: 6633ce9 Since your last pass the branch also gained a second commit refreshing the vendored Specific things I would like scrutinised:
|
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 23f0a44
Critical Issues (1)
- [native-codex]
vendor/paperclip-adapter-claude-k8s/src/server/env-guard.ts:926— the supposedly pod-unique install temp is only keyed by$$; the settings merge temp at line 888 has the same problem withprocess.pid. Pods have separate PID namespaces, so concurrent startups on the shared HOME can generate the same temp pathname. Each writer then truncates that shared temp with>, and the writers race onmv, recreating the shared-file metadata contention and potentially losing one writer's settings update.- Use a genuinely shared-volume-unique temp created atomically (for example
mktempin the target directory, or an equivalent exclusive-create flow), and add a test that runs two independent installers concurrently against one shared directory and verifies both complete without clobbering the result.
- Use a genuinely shared-volume-unique temp created atomically (for example
Important Issues (0)
Suggestions (1)
- [pr-review-toolkit]
vendor/paperclip-adapter-claude-k8s/src/server/env-guard.test.ts:347— test the generated shell concurrently from separate processes and assert the temp paths are distinct; the current regex test locks in$$rather than proving cross-pod uniqueness.
Strengths
- The content-addressed guard filename routes around the known poisoned inode.
- The settings merge preserves unrelated hooks and removes stale guard entries.
- The vendored integrity hash and focused regression coverage were updated.
Recommended Action
- Fix the Critical issue before merge.
- Address Suggestions opportunistically.
Thinking Path
Linked Issues or Issue Description
No pre-existing issue — describing inline per CONTRIBUTING.md path (B), bug report shape.
What happened. Agent pods hung fleet-wide on their Bash
PreToolUsehook. On the MDS: 68+ requests stuck at flag pointacquired locks, oldest ~4h, from 8 distinct nodes, allsetattr size=0against the single inode backing.claude/paperclip-env-guard.mjson the sharedpaperclip-datavolume. Hungumount/syncon two nodes, which is what blocked draining them.Expected. Installing a hook file at pod startup does not contend with every other pod in the fleet.
Actual. Every pod truncated the same inode. One stuck truncate blocked all subsequent ones permanently.
Not a capacity or client problem. MDS journal, objecter and OSDs were healthy throughout (journal latency 12ms,
op_laggy0). The client named by theMDS_CLIENT_OLDEST_TIDwarning was itself healthy — releasing caps ~110k/min withrecall_caps0 — so the warning misdirects; the fault is on the inode, not the client. Dropping that client's cache halved its caps and changed nothing.Three online remedies do not clear it (all verified against the live cluster):
ceph mds fail <rank>scrub start <path> recursive,repair,forceThat third result is why the filename has to change: the poisoned dentry can be neither replaced nor removed, so recovery has to route around it.
What Changed
buildEnvGuardSetupShellno longer truncates. A newinstallOncehelper writes each script to a pod-unique temp (.<name>.$$.tmp) andmv -fs it into place, guarded by[ -f "$target" ] ||so the write happens only when the target is absent.test -fstats the dentry and never opens it, so the check itself cannot block on a poisoned inode.paperclip-env-guard.<sha256[:12]>.mjs. A guard change lands as a new file instead of rewriting the shared one.PAPERCLIP_GUARD_FILEso the settings merge targets the same content-addressed path.SETTINGS_MERGE_SCRIPTnow writessettings.jsonvia temp +renameSyncinstead of an in-placewriteFileSync— the identical shared-volume hazard, one landmine away from the same outage — and prunes stalePreToolUseentries pointing at a previous guard file, so pods stop invoking a wedged path.safe-env-inspect.mjskeeps its stable name (ENV_GUARD_SCRIPTnames it literally in the block message) but is install-once too, so it is never truncated either.PROVENANCE.md: refreshed the vendored integrity hash and logged the patch in the Local modifications table.Verification
env-guard.test.ts279 pass / 0 fail. Full vendored suite in CI: 889 pass across 16 files.env-guard.tsto its previous implementation and re-running the new tests fails exactly 3 —never truncates a shared file in place,content-addresses the guard filename so a change lands as a new inode, and the updated merge test. The tests fail for the right reason rather than passing vacuously.sh -n.New coverage asserts every redirect targets a
.tmppath, every install is create-if-absent landing viarename, the guard name is content-addressed, and a stale guard hook entry is pruned rather than merely deduplicated.Risks
settings.json, which the merge script rewrites on startup, and the stale entry is pruned in the same pass.safe-env-inspect.mjs, whose name is deliberately stable, a future content change would not propagate to volumes that already have the file. That helper is two lines and has never changed; flagged here rather than left implicit, and called out for review below.|| echos and cannot block a run from starting.Model Used
Claude Opus 5 (Anthropic), model ID
claude-opus-5, 1M context window, extended thinking, with tool use and code execution. Diagnosis ran against the live Ceph cluster and Kubernetes API; the fix and its tests were authored and executed locally.Checklist
env-guard|envguard|vendor|truncate|ceph; the only hits are keyword noise on "guard" (fix(heartbeat): retry k8s_concurrency_guard_unreachable runs (BLO-17938) #1803, test(recovery): cover the backstop shared-cursor guard with interleaved invocations #1743, fix(heartbeat): withhold a row a sibling run is parked on via scheduled retry (BLO-29965) #1742, fix(ally-guard): classify a same-lane duplicate review by body, not by timing #1738, fix(redaction): mask five vendor credential shapes on the run-log free-text path (PEN-3139) #1736, fix(heartbeat): stop coalescing from inheriting the status-only guard onto a declared normal-model wake (BLO-32634) #1718, test(deploy): pin the two widest citation ranges' interiors, not just their first lines (BLO-32210) #1686, fix(review-gate-sweep): re-check ally_has_reviewed_head before the write (BLO-32044) #1667, fix(review-gate-sweep): re-read markers and re-check ally_has_reviewed_head before the write (BLO-31908, BLO-32044) #1661), none of which touch the vendored adapter. No duplicates.Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template🤖 Generated with Claude Code