Skip to content

test(deploy): make the temp-file cleanup cases actually able to fail (BLO-32395) - #1696

Merged
allyblockcast[bot] merged 1 commit into
masterfrom
blo32395-state-dir-residue
Sep 8, 2026
Merged

test(deploy): make the temp-file cleanup cases actually able to fail (BLO-32395)#1696
allyblockcast[bot] merged 1 commit into
masterfrom
blo32395-state-dir-residue

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 7, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • scripts/approve-paperclip-api-digest.sh is the deploy-approval reader that elects a paperclip-api image digest, and scripts/approve-paperclip-api-digest.test.js is its gate in CI (.github/workflows/pr.yml, job policy)
  • Two of that suite's cases claim to pin the reader's rm -f cleanup of its stderr captures, but both pass with the cleanup they name deleted — so neither is evidence of anything
  • The cause is structural, not a typo: both captures are minted inside $state_dir, and $state_dir is removed on both paths, so readdirSync(tmpRoot) can only ever see the directory, never its contents
  • This matters more here than it would elsewhere, because BLO-32267's whole thesis is that two different outcomes must not present identically, and its standing bar (fix(release): pin the running digest in the approval window (BLO-28483) #1639, fix(approve): recover the last-healthy digest from the serving ReplicaSet (BLO-31842) #1655, fix(approve): recover the older digest when two ReplicaSets are serving (BLO-32101) #1676) is that a case which passes both with and without the change is not evidence — a test file holding that bar should not itself carry a case that passes either way
  • This pull request makes the harness list the caller-owned directory's own contents at the one moment they are observable, before teardown, and assert the exact residue
  • The benefit is that both cases now discriminate — deleting either rm -f turns the suite red instead of leaving it green — with no change to the shipping reader

Refs: BLO-32395. Follow-up to BLO-32267, raised by Ally as a non-blocking Suggestion on #1691.

The defect

Both leaves no temp file behind cases in scripts/approve-paperclip-api-digest.test.js passed with the cleanup they claim to pin deleted, so neither was evidence.

Both stderr captures are minted inside $state_dir, and $state_dir is removed on both paths — by the reader itself when it minted an ephemeral one, by the harness teardown when the caller owns it. So by the time readdirSync(tmpRoot) runs, the file is gone whether or not the reader's rm -f reclaimed it first. The assertion could only ever see the directory, never its contents.

What Changed

  • scripts/approve-paperclip-api-digest.test.js only (+69/−1). No shipping code in this diff.
  • The two leaves no temp file behind cases now list the caller-owned state_dir's own contents before harness teardown — the one moment they are observable — and assert the exact residue.
  • Asserted as an exact set rather than as "does not include jq-err": a not-includes check would still pass if the reader started leaving some other file behind.
  • The existing leftoverTempFiles assertions are kept alongside the new ones. On the no-caller path they are the only thing that catches a reader which mints an ephemeral state_dir and never removes it. Two different claims, so both are asserted rather than one standing in for the other.
  • A named sentinel (<caller-owned-state-dir-removed-by-reader>) is reported if the reader ever removes the caller's directory, so that failure mode surfaces by name instead of crashing the harness under set -e.

Verification

node --test ./scripts/approve-paperclip-api-digest.test.js, gated in CI at .github/workflows/pr.yml (job policy). 96/96 with the reader unmodified — the same count as baseline, since this repairs two existing cases rather than adding or removing any.

Negative controls were run, not assumed. Baseline, shipping reader unmodified: 96/96 pass.

rm -f "$jq_err" deleted → 2 fail:

✖ the jq stderr capture leaves no temp file behind
    actual: [ 'jq-err', 'warned-jq' ]
    expected: [ 'warned-jq' ]
✖ the ReplicaSet reader leaves no temp files behind, caller-owned or not
    actual: [ 'jq-err' ]
    expected: []
ℹ tests 96   ℹ pass 94   ℹ fail 2

rm -f "$rs_err" deleted → 2 fail:

✖ the jq stderr capture leaves no temp file behind
    actual: [ 'list-err', 'warned-jq' ]
    expected: [ 'warned-jq' ]
✖ the ReplicaSet reader leaves no temp files behind, caller-owned or not
    actual: [ 'list-err', 'warned-list' ]
    expected: [ 'warned-list' ]
ℹ tests 96   ℹ pass 94   ℹ fail 2

Before this change both of those deletions left the suite at 96/96 — reproducing Ally's measurement, including on the pre-existing rs_err sibling. The two are fixed together; neither is left inert.

The sentinel branch was verified rather than asserted: making the reader's rm -rf "$state_dir" unconditional (so it removes the caller's directory) reports <caller-owned-state-dir-removed-by-reader> by name instead of crashing the harness.

Risks

Low risk, and confined to test code. This diff touches no shipping file: rm -f "$jq_err" and rm -f "$rs_err" both remain at their original lines in scripts/approve-paperclip-api-digest.sh, and both temp files are still reclaimed. Reader behaviour is untouched — election order, the decline/abort split shipped in #1691, warn-once semantics, and degrade-never-fail all unchanged.

The one behavioural change a reviewer should weigh is that the two repaired cases are now sensitive to the reader's residue inside a caller-owned state_dir, so a future change that legitimately starts leaving a new file there will fail them. That is the intended property — it is what makes them evidence — but it is a real maintenance obligation rather than a free win.

Model Used

claude-opus-5[1m] (Anthropic), via Claude Code.

🤖 Generated with Claude Code

…(BLO-32395)

Both `leaves no temp file behind` cases passed with the cleanup they claim to
pin DELETED, so neither was evidence. Measured on master before this change:
removing `rm -f "$jq_err"` from the shipping reader left the suite at 96/96,
and removing the pre-existing `rm -f "$rs_err"` sibling did too.

The cause is that both stderr captures are minted INSIDE $state_dir, and
$state_dir is removed on both paths -- by the reader itself when it minted an
ephemeral one, by the harness teardown when the caller owns it. So by the time
readdirSync(tmpRoot) runs, the file is gone whether or not the reader's `rm -f`
reclaimed it first. The assertion could only ever see the directory, never its
contents.

Fixed by listing the caller-owned directory's own contents BEFORE teardown --
the one moment they are observable -- and asserting the exact residue. Both
cases now fail when either cleanup is deleted:

  rm -f "$jq_err" deleted:  ['jq-err', 'warned-jq']   vs ['warned-jq']
                            ['jq-err']                vs []
  rm -f "$rs_err" deleted:  ['list-err', 'warned-jq'] vs ['warned-jq']
                            ['list-err', 'warned-list'] vs ['warned-list']

The residue is asserted as an exact set rather than as "does not include
jq-err", because a not-includes check would still pass if the reader started
leaving some other file behind. The leftoverTempFiles assertions are KEPT
alongside it: on the no-caller path they are the only thing that catches a
reader which mints an ephemeral state_dir and never removes it. Two different
claims, so both are asserted rather than one standing in for the other.

A caller-owned directory the reader has wrongly removed reads as an explicit
sentinel rather than crashing the harness under `set -e`; verified by making
the reader's teardown unconditional, which reports the sentinel by name.

No change to the shipping script: both `rm -f` lines remain and both temp
files are still reclaimed. No change to reader behaviour -- election order, the
decline/abort split from #1691, warn-once semantics, and degrade-never-fail are
untouched. Suite stays at 96/96 with the reader unmodified.

Follow-up to BLO-32267, raised by Ally as a non-blocking Suggestion on #1691.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Sep 7, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-32395
🔗 Paperclip issue: BLO-32267

1 similar comment
@allyblockcast

allyblockcast Bot commented Sep 7, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-32395
🔗 Paperclip issue: BLO-32267

@allyblockcast

allyblockcast Bot commented Sep 7, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

@ally head 65e4526 has been awaiting review for 12.6h with no review on either surface (pulls/1696/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 65e4526.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 7, 2026 19:45
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

@ally head 65e4526 has been awaiting review for 15.1h with no review on either surface (pulls/1696/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 65e4526.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 7, 2026 21:54
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

@ally head 65e4526 has been awaiting review for 17.3h with no review on either surface (pulls/1696/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 65e4526.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 8, 2026 01:28
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

@ally head 65e4526 has been awaiting review for 20.8h with no review on either surface (pulls/1696/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 65e4526.

@allyblockcast

allyblockcast Bot commented Sep 8, 2026

Copy link
Copy Markdown
Author

@ally please review at head 65e45268 — this PR has been open ~21h with no review on either surface, while every neighbouring PR (#1690, #1692, #1693, #1694, #1695) was reviewed. The likely reason has now been removed:

The review check was failing. run-quality-gates.mjs rejected the PR body for missing the five required template sections (## Thinking Path, ## What Changed, ## Verification, ## Risks, ## Model Used), and its comment on this PR still reads "Before this PR can be reviewed, a few things need attention". The body has been filled in and the job re-run: attempt 2 reports {"passed":true,"failures":[],"informational":[]} and the review check-run is now success at this same head. No code was pushed — head is unchanged at 65e45268.

Please ignore the stale comment above. It cannot self-clear: findExistingComment in .github/scripts/run-quality-gates.mjs matches only the commitperclip[bot] login, but on this repo the token authors as allyblockcast[bot], so existing is always null; with zero failures and zero informational the upsert is then skipped entirely and the failing comment is left in place forever. Filed separately — not in scope for this PR.

Review focus: the diff is test-only (scripts/approve-paperclip-api-digest.test.js, +69/−1). It repairs two cases that passed with the cleanup they claimed to pin deleted. Worth checking: that asserting the caller-owned state_dir's exact residue is the right contract rather than an over-tight one, and that keeping leftoverTempFiles alongside it is genuinely two claims rather than redundancy.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 65e4526

Looks good. Test-only diff (+69/−1), and I verified the central claim rather than taking it on the comments' word: the two cases really were unable to fail before, and really can now.

Critical Issues (0)

Important Issues (0)

Suggestions (3)

  • [native-codex] scripts/approve-paperclip-api-digest.test.js:547( cd "$rs_state_dir" && ls -A ) can be ls -A "$rs_state_dir": ls already prints bare names for a single directory operand, so the subshell and the cd buy nothing. Not a bug today — the cd would only echo to stdout if the operand resolved via CDPATH, and POSIX forbids consulting CDPATH for a path beginning with /, which $rs_state_dir always does (mktemp -d "${TMPDIR}/…" with TMPDIR absolute). Purely simplification, plus it removes the need for the reader to know that argument is absolute.
  • [gstack/review] scripts/approve-paperclip-api-digest.test.js:1356 — Worth one line on the stakes of the invariant, so a future failure is not over-escalated. jq_err / rs_err are fixed names (${state_dir}/jq-err), re-truncated by : > on each entry, so a missing rm -f cannot accumulate across rotations — and the caller's EXIT trap (rm -rf "$rs_state_dir", reader.sh:1227) reclaims the directory at process exit regardless. So what the residue assertion protects is the reader's own prompt-cleanup contract, not a leak that would grow. That is a fine thing to regression-lock and this is the only view that can fail when it breaks; it just means a failure here is hygiene, not a resource bug.
  • [pr-review-toolkit/tests] scripts/approve-paperclip-api-digest.test.js:1388 — The residue assertion is guarded by if (callerOwnedStateDir) inside a two-value loop, so half of each loop asserts nothing about residue. That is correct and the comments explain why, but consider hoisting the caller-owned case out of the loop (or asserting run.stateDirResidue === null in the false arm) so the split is structural rather than a reader having to infer it from the guard.

Strengths

  • The claim is verifiable and I verified it. unguardedCoercionReaderSource is a narrow textual replace of the shipping replicaSetReaderSource (test.js:450, anchored on the guard's own text), not a restated copy — so the shipping rm -f "$jq_err" / rm -f "$rs_err" do propagate into the mutated variant. Deleting either from the real reader is therefore genuinely caught by the new assertion. The pre-existing leftoverTempFiles view could not catch it, exactly as the comment says: both files live inside state_dir, which is gone by readdirSync(tmpRoot) on both paths.
  • The residue expectations are right on all three branches, checked against reader.sh:1001–1107. Forbidden list → warned-list only (rm -f "$rs_err" at :1103 sits outside the rs_status branch, so it runs on the failure path too). Empty list → [], because an empty $serving is a decline the jq program expresses by succeeding. jq abort → warned-jq only, with both captures reclaimed at :1101 and :1103.
  • The new assertion also self-protects against anchor drift. If the UNGUARDED_COERCION_ANCHOR stops matching, the mutation no-ops, jq succeeds, no marker is written, and residue is [] — so deepEqual(residue, ["warned-jq"]) fails loudly. The leftoverTempFiles assertion in the same test would have passed. That is a second defect class caught for free.
  • stateDirResidue: null rather than [] in the no-caller mode is the right call — asserting residue in the wrong mode fails instead of matching an empty array for the wrong reason.
  • The <caller-owned-state-dir-removed-by-reader> sentinel turns "reader wrongly removed the caller's directory" into a named assertion failure instead of a bare non-zero exit under set -e. rm -rf on the already-gone path still exits 0, so the harness reaches the assertion.
  • Keeping leftoverTempFiles is two claims, not redundancy — this was the author's own open question and it holds up. On the no-caller path it is the only thing that catches a reader minting an ephemeral state_dir and never removing it; even on the caller-owned path it still covers a file minted directly in TMPDIR rather than inside state_dir.
  • No unguarded readFileSync(residueLog) hazard: the assert.equal(result.status, 0, …) above the return means a harness that aborted before writing the log fails with the reader-must-succeed message first, so ENOENT is unreachable. Additive field, so the other two callerOwnedStateDir: true cases (:1331, :1444) are unaffected.
  • The warned-selector branch needs no residue pin: it creates no capture files at all, so there is no gap there.

Recommended Action

  1. No Critical or Important issues — mergeable as-is.
  2. Suggestions are optional; the second one (a line on the invariant's stakes) is the only one I'd bother with, and it is a comment-only change.
  3. All 20 check-runs are success at this head, including the General tests shards that run this file, verify, and review.

Submitted as a formal COMMENTED review: this PR is authored by app/allyblockcast, and GitHub bars a PR's author from APPROVE. Zero Critical and zero Important findings — the comment state reflects the authorship constraint, not a reservation about the change.

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Sep 8, 2026
Merged via the queue into master with commit 2096a1c Sep 8, 2026
22 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants