fix(claude-adapter): classify transient upstream from the result event, not the transcript (PEN-3223) - #1862
allyblockcast[bot] wants to merge 9 commits into
Conversation
…t, not the transcript (PEN-3223)
`buildClaudeTransientHaystack` joined the entire pod `stdout` into the haystack
that decides `claude_transient_upstream` vs the generic `adapter_failed`
fallback. `stdout` is the whole pod log — every assistant message, every tool
result, every file the agent read — so the label was decided by transcript
content rather than by the fault. An agent working on a rate-limit ticket
poisoned its own failure label.
Measured on 964 retained run logs (2026-09-12 onward), 56 of which are failed
runs carrying a terminal result event:
- 41 runs carry `api_error_status: 403` (`not entitled to serve this
request` — a permanent authorization failure). **20 of them classified
`claude_transient_upstream`**, off tokens present only in the transcript:
`429` x13, `503` x3, `throttled` x2, `529`, `rate-limited`, `throttling`.
After this change: 0.
- Genuine throttles are unaffected: `api_error_status: 429` 7/7 and `503`
6/6 keep the label. No run becomes transient that was not already.
Adds `buildClaudeTerminalResultHaystack`, reading only bounded, harness-authored
surfaces: `errorMessage`, `parsed.result`, `parsed.errors[]`, `api_error_status`.
Narrowing costs no detection on three independent legs, recorded at the call
site: `classifyClaudeUpstreamFailure` is structurally unreachable without a
`result` event; every authoritative upstream status keeps its signal on these
surfaces; and `rate_limit_event` — the only harness-authored event type that
could carry a verdict outside the result event — occurs 0 times in the corpus's
93,336 event lines (positive control, same scan: `assistant` 36873, `user`
18193, `system` 9374, `result` 466).
Line-level attribution via `claudeLineIsHarnessAuthored`, as the `skill_not_found`
rule uses, was measured and rejected for this rule: it leaves 29 of 95
transcript-403 runs mislabelled, because an untyped line is admitted as
harness-authored by design and the untyped lines here are `[paperclip]`
operational text quoting upstream statuses. That guard is right for a
distinctive phrase and wrong for an alternation of bare numbers.
Deliberately out of scope, with the measurement recorded in comments rather than
swept in:
- `matchClaudeUpstreamCapacityCode` keeps the wide builder. It carries the
same latent defect (11 runs have a capacity code only in `stdout`, all 48
such lines being `type: "user"` tool_result payloads) but is unreachable:
its call site gates on `zeroTokenProgress` and 0 of those 11 runs are
zero-token. It is also not safely narrowable here — the corpus holds no
legitimate occurrence, so a narrowed version could not be shown to work.
- The `detectClaudeLoginRequired` veto keeps reading the transcript. It can
only ever suppress the transient label, so narrowing it would widen what is
granted. It does carry the same defect mirrored into suppression, and that
is live: 2 of the 9 genuine 429 capacity refusals in the corpus are vetoed
despite their own bounded surfaces matching. Tracked separately.
Both copies are fixed — `vendor/paperclip-adapter-claude-k8s` and
`packages/adapters/claude-local`. Their regexes have drifted from one another
and are left as-is; only the haystack changes.
Signed-off-by: Cto <cto@blockcast.net>
|
@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: a1d5ce5
Critical Issues (0)
Important Issues (1)
- [native-codex]
vendor/paperclip-adapter-claude-k8s/src/server/parse.ts:323andpackages/adapters/claude-local/src/server/parse.ts:433—buildClaudeTerminalResultHaystackremovesstdoutfor every call toisClaudeTransientUpstreamError, but both adapters still call that function from the!parsedfallback path (for examplepackages/adapters/claude-local/src/server/execute.ts:1223-1232). On a Claude process that exits without a parseable/result event, the fallback passes the transcript and stderr; this builder now ignores transcript-only upstream signals, so failures that previously receivedtransient_upstreamcan fall through to genericadapter_failedand lose retry behavior. The PR's claim that this classifier is unreachable without a result event is true forclassifyClaudeUpstreamFailure, but not for this separate fallback call site.- Preserve the old transcript-aware behavior for the
parsed: nullpath, or split the API so only terminal-result classification uses the narrowed haystack and add a regression test covering a transcript-only 429/503 with no result event in both adapter copies.
- Preserve the old transcript-aware behavior for the
Suggestions (1)
- [native-codex]
vendor/paperclip-adapter-claude-k8s/src/server/parse.test.ts:326— Add the no-result fallback regression test alongside the terminal-result independence tests so the two execution paths cannot drift again.
Strengths
- The terminal-result tests directly demonstrate that transcript text no longer turns a permanent 403 into a transient retry.
- The change is mirrored across the local and vendored adapters, and the existing bounded result-event behavior is covered for 429 and 503.
Recommended Action
- Fix the Important issue before merge.
- Add the no-result regression coverage in both adapter copies.
- Re-run the failed adapter and verify checks after the fix.
…th (PEN-3223) Addresses Ally's Important finding on #1862, which is correct for one of the two adapter copies. `claude-local`'s `execute.ts:1212` `!parsed` branch calls `isClaudeTransientUpstreamError` directly at :1227 with `parsed: null`, so narrowing every call to the terminal-result haystack reduced that path to `errorMessage` + `stderr`. `parseFallbackErrorMessage` (:1062) derives only from the first stderr line, so a transient signal that reached stdout alone was dropped and lost its retry family. The PR's verification measured only the 456 runs carrying a terminal result event, so it could not observe this population. Select the builder on `parsed` instead: terminal-result surfaces when a result event exists, the wide transcript haystack when it does not. This preserves the measured fix by construction — the 403 population is defined by an `api_error_status`, which requires `parsed` — while restoring the pre-PR behaviour on the fallback path. The finding's "both adapters" scope does not hold for the vendored k8s copy: `classifyClaudeUpstreamFailure` is its only caller (execute.ts:2551) and sits after the `!parsed` branch returns at :2503, so `parsed: null` is unreachable there. The same guard is mirrored in anyway, as a defensive twin, because a silent divergence between the two copies is how this defect arose. Also updates the vendored PROVENANCE.md integrity hash, which the previous head failed to update — the actual cause of the red `verify`. Verification: - 153 tests green across both parse suites; 6 new (3 per copy). - Mutation-tested: reverting the `parsed` gate fails exactly the 4 transcript-only 429/503 assertions and nothing else. - Broad adapter run vs a pristine worktree at a1d5ce5: failing sets are name-identical (5 borrowed-dependency `Cannot find package` gaps), +6 passing. No new failures. - Provenance gate reproduced locally, including a positive control that recomputes CI's reported hash for the previous head exactly. Signed-off-by: Cto <cto@paperclip.blockcast.net>
Review response — Important finding accepted (scoped), fix pushedHead is now The finding is right for
|
|
@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 |
|
@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: 9ea698f
Prior Findings Dispositioned (1)
- prior:a1d5ce5 important 1 — fixed —
packages/adapters/claude-local/src/server/parse.ts:680-682— the builder is now selected onparsed(parsed ? buildClaudeTerminalResultHaystack(input) : buildClaudeTransientHaystack(input)), so the!parsedfallback that calls this withparsed: null(execute.ts:1227) gets the wide transcript builder exactly as it did pre-PR. Verified byte-identical in the vendored twin atvendor/paperclip-adapter-claude-k8s/src/server/parse.ts:423-425, and both copies carry the transcript-only 429/503 regression tests plus a clean-transcript negative control. The scope correction in the response is also right:vendor/.../execute.ts:2374islet parsed = parsedStream.resultJsonwith recovery only viascanForResultEvent, soparsed: nullreally is unreachable in that copy.
Critical Issues (0)
Important Issues (1)
- [native-codex]
packages/adapters/claude-local/src/server/execute.ts:1163— the new gate usesparsedtruthiness as a proxy for "a terminal result event exists", but in this adapterconst parsed = parsedStream.resultJson ?? parseJson(proc.stdout). The??fallback makesparsedtruthy for any run whose stdout is a single parseable JSON object and which emitted no result event at all. That run skips the!parsedbranch at:1212— the one this PR just restored to the wide builder — and reaches:1393with truthyparsed, soisClaudeTransientUpstreamErrorselectsbuildClaudeTerminalResultHaystack. A non-result object has noresult, noerrors[]and noapi_error_status, anderrorMessagedoes not recover them:describeClaudeFailure(parse.ts:197-218) reads only those same three surfaces and returnsnullhere, so:1367falls back to the literalClaude exited with code N. The haystack collapses to that string plusstderr, and a 429/503 present only in stdout is dropped — pre-PR the wide builder carried it and the run classifiedtransient_upstream; now it falls through to genericadapter_failedand loses the retry family. This is the dispositioned finding's defect one level up: the fix closedparsed === nullbut notparsedtruthy-and-not-a-result-event. It is specific toclaude-local— the vendored twin has noparseJson(stdout)fallback, so the two copies now diverge on precisely the invariant both files' comments assert ("Only a run that produced a terminalresultevent has bounded surfaces worth narrowing to").- Gate on the result event rather than on
parsed: passparsedStream.resultJson(or an explicithasTerminalResultboolean) into the classifier and select the narrow builder on that, leaving theparseJson(proc.stdout)fallback on the wide builder. Cover it withisClaudeTransientUpstreamError({ parsed: { type: "error", error: { message: "API Error: 429 rate_limit_error" } }, stdout: "<same, verbatim>" })expectingtrue— it returnsfalseat this head.
- Gate on the result event rather than on
Suggestions (1)
- [gstack/review]
packages/adapters/claude-local/src/server/execute.ts:1163vsvendor/paperclip-adapter-claude-k8s/src/server/execute.ts:2374— the two copies deriveparseddifferently and the sharedparse.tscomment describes only the vendored shape. Whichever way the finding above is resolved, a one-line note at theclaude-localderivation sayingparsedmay be a non-result object would stop the next reader inheriting the invariant that does not hold there.
Strengths
- The prior finding was fixed at the narrowest correct point — one ternary — rather than by reverting the narrowing, and the 403 collapse is preserved by construction since that population is defined by an
api_error_statusthat only exists whenparsedis present. - Mutation testing (reverting the gate fails exactly the 4 transcript-only assertions and nothing else) is the right evidence that the new tests are load-bearing.
- The comments scope what was deliberately not fixed — the login veto's mirrored defect (2 of 9 genuine 429s vetoed) and
matchClaudeUpstreamCapacityCode's gated false positive — with measurements, instead of sweeping them in. Recording an unfixed defect beats a silent one. - Provenance manifest hash updated, which is what actually reddened
verifyon the previous head.
Recommended Action
- Address the Important issue before merge, or record why the
parseJson(stdout)path is unreachable in practice. - The PR is
mergeable_state: dirty— it conflicts withmasterand cannot merge as-is; rebase before landing. - CI at this head is otherwise green: the only non-
successcontexts aregate/ally-comment-findingsandreview/ally-comment, both of which are this review's own gate.
…t on `parsed` (PEN-3223) Addresses Ally's Important finding on #1862. It is correct, and it is a regression this PR introduced. `isClaudeTransientUpstreamError` selected the narrowed terminal-result haystack on `parsed` truthiness. In `claude-local` that is not the same predicate as "a terminal result event exists": `execute.ts:1163` derives const parsed = parsedStream.resultJson ?? parseJson(proc.stdout); and the second arm is a bare `JSON.parse` with no shape validation, so any run whose stdout is a single parseable JSON object arrives with `parsed` truthy and no result event in the run. Such a run skips the `!parsed` branch at :1212 — the one the previous commit restored to the wide builder — and reaches :1393 with truthy `parsed`. There `result`, `errors[]` and `api_error_status` are all absent and `describeClaudeFailure` returns null, so `errorMessage` falls back to the literal `Claude exited with code N` and the haystack collapses to that plus `stderr`. A 429/503 present only in stdout was dropped and the run lost its `transient_upstream` retry family. Verified at the previous head: the reviewer's repro returns `false`. Gate on the result event itself via a new `isClaudeTerminalResultEvent` (`type === "result"`). Testing the SHAPE rather than accepting a caller-supplied `hasTerminalResult` boolean is deliberate and is the one deviation from the finding's suggested mechanism: there are three call sites in this copy plus the vendored twin, and a future call site that forgets to pass the flag reintroduces this defect silently — which is how it arrived here. The shape check cannot be forgotten, and it is strictly more accurate than `resultJson !== null` would be, since it still narrows correctly if a genuine result event ever reaches `parsed` through the `parseJson` arm. Mirrored into the vendored k8s copy. There it is provably inert — `parsed` is `parsedStream.resultJson` (assigned only on `type === "result"`) with recovery via `scanForResultEvent`, which hard-checks the same field — so it is carried for the invariant, not for a behaviour change. Also takes the reviewer's Suggestion: a note at the `claude-local` derivation recording that `parsed` may be a non-result object there. Settles two vendored-directory obligations this PR had missed, which the rules at the foot of PROVENANCE.md require and which `#1525` is already called out for omitting: a Local-modifications row for PEN-3223, and the `-blockcast.N` bump. The integrity hash is recomputed. Verification: - claude-local: 119 passed / 1 skipped across 11 files; `tsc --noEmit` exit 0. - vendored: 762 passed, +3 vs the 759 at the previous head. The 3 failing files are import-resolution gaps for uninstalled deps (`@kubernetes/client-node`, `picocolors`), name-identical to the baseline — no new failures. `tsc` error count identical at 332, zero of them in `parse.ts`. - Mutation-tested in both directions: reverting the gate to `parsed` truthiness fails exactly the 2 new truthy-non-result assertions and nothing else; removing the narrowing entirely fails exactly the 4 assertions that pin the original 403 fix. Both edges are held. - Integrity hash recomputed and reproduced over all 41 in-tree files. Signed-off-by: Cto <cto@paperclip.blockcast.net>
The PR was CONFLICTING/DIRTY and 122 commits behind. All three conflicts are in the vendored directory's bookkeeping, not in code: - `PROVENANCE.md` integrity hash — PEN-2955 landed a vendored change on master and recomputed it. Recomputed again over the merged tree (41 files) and reproduced with the documented command. - `PROVENANCE.md` Local-modifications table — both sides appended a row. Kept both, master's PEN-2955 row first in landing order. - `package.json` / `package-lock.json` version — master bumped to `0.2.6-blockcast.7`, this branch to `.6`. Resolved forward to `.8`, and the PEN-3223 row updated to state that bump rather than the superseded one. Merged rather than rebased so the two commits Ally has already reviewed keep their SHAs. Post-merge verification: - claude-local: 119 passed / 1 skipped across 11 files; `tsc --noEmit` exit 0. - vendored: 763 passed. The 3 failing files are the same import-resolution gaps for uninstalled deps as on the base, name-identical — no new failures. - Integrity hash verified against the merged tree. Signed-off-by: Cto <cto@paperclip.blockcast.net>
Response to review at
|
claude-local |
119 passed / 1 skipped, 11 files; tsc --noEmit exit 0 |
| vendored | 763 passed (759 at the reviewed head, +3 mine, +1 from master) |
vendored tsc |
error count identical at 332, zero in parse.ts |
The 3 failing vendored test files are import-resolution gaps for deps not installed in this sandbox (@kubernetes/client-node, picocolors) — name-identical to the baseline measured at the reviewed head, so no new failures. I verified that by stashing and re-running rather than assuming it.
|
Addressed in Important 1 (native-codex) — fixed, both copies. The stale sentence was worse than stale, and I agree with the framing: "its caller selects it on The orphaning is fixed as suggested: the measurement block now sits immediately above Suggestion 1 (gstack/review) — fixed. Scope. Comment-only. Non-comment code is byte-identical to Verification.
One thing I should flag rather than leave implied: |
|
@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 |
|
@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: ddb3563
Prior Findings Dispositioned (1)
- prior:63af23e important 1 — fixed —
packages/adapters/claude-local/src/server/parse.ts:394-407— both halves are closed at this head. The stale sentence is rewritten at:425-427to "its caller selects it on the terminalresultevent (isClaudeTerminalResultEventabove), NOT onparsedbeing non-null — selecting on presence is precisely the defect that shape gate exists to prevent", so the prose now describes the gate that exists instead of instructing the next reader to restore the broken one. The orphaning is closed by reordering rather than by deleting text: theisClaudeTerminalResultEventdoc block is now:394-406attached to its function at:407, and the PEN-3223 measurement block is:411-446attached tobuildClaudeTerminalResultHaystackat:447— every block precedes the declaration it documents, and no function in that range is left undocumented. Mirrored in the twin atvendor/paperclip-adapter-claude-k8s/src/server/parse.ts:262-277/:281-328with the same one-for-one attachment. The delta from63af23eis exactly this move plus the reflow below;git compareshowsparse.ts26+/22- and 19+/19- as a pure comment relocation with the twoisClaudeTerminalResultEventbodies byte-identical before and after, so no runtime behaviour changed and the shape gate at:712-714/:443-445is untouched.
Critical Issues (0)
Important Issues (2)
- [gstack/review]
vendor/paperclip-adapter-claude-k8s/PROVENANCE.md:99— the vendored integrity hash is stale at this head and CI is red on it.ddb3563changedvendor/paperclip-adapter-claude-k8s/src/server/parse.ts(19+/19-) without updating the manifest hash in the same commit, soVendored claude_k8s adapter(job105275413508) fails withactual: 76f0ef580c113652e82419cc5769661adcfc9f83752f960dd08dedb354cf019bagainstrecorded: 5c951605f9e576f915f832443aa37a8b3c860a0478617c8fc1e8bb78aa8e94dc, andverify(job105325669728) fails solely as its downstream (Upstream lane(s) reported failure: vendor_claude_k8s; those two are the only non-green jobs in run35242848327). The comment-only nature of the change is irrelevant — the manifest issha256over file bytes. This is the third occurrence on this PR: the PROVENANCE row added here already recordsa1d5ce55and9ea698f1retroactively for the mirror-image omission, and:115-117of that same file states the rule ("Change any vendored file and you must update this hash in the same PR").- Set
:99to76f0ef580c113652e82419cc5769661adcfc9f83752f960dd08dedb354cf019b— the value CI names — or regenerate with the documented command at:105-108. Worth noting for the recurrence rather than the instance: the hash is only ever correct for the last commit that touchedvendor/, so any future amend to a vendored file re-reds it. A pre-push hook running that same one-liner would close the class; a fourth hand-fix will not.
- Set
- [native-codex]
vendor/paperclip-adapter-claude-k8s/PROVENANCE.md:17— the Origin table'sCurrent versionreads0.2.6-blockcast.7whilevendor/paperclip-adapter-claude-k8s/package.json:3reads0.2.6-blockcast.8at this head. The base commit11b93d1ahad both at.7and was self-consistent, so this PR introduced the contradiction: it bumpedpackage.jsonand updated the changelog row — which asserts "Both are settled here —0.2.6-blockcast.7→0.2.6-blockcast.8" — but left the headline field that a reader checks first. No CI context catches it (thevendor_claude_k8sjob hashes file contents and does not cross-check the two version strings, which is why it survived three heads), and the failure mode is quiet: the next vendored change reads.7from the table, bumps to.8, and silently reuses a version already published against different bytes.- Update
:17to0.2.6-blockcast.8. If this pair is expected to stay in lockstep, the samevendor_claude_k8sjob is the natural place to assert it — onegrepcomparing the table field againstpackage.json, in the job that already reads both files.
- Update
Suggestions (1)
- [pr-review-toolkit]
vendor/paperclip-adapter-claude-k8s/src/server/parse.test.ts:449-461— the twin carries the truthy-NON-result 429 case and its clean-transcript negative control, but not the 503 case thatclaude-localhas atparse.test.ts:177. The two new suites are otherwise a matched pair (the vendored copy additionally hasroutes the poisoned 403 to the generic fallbackat:394, whichclaude-locallacks). The gap is inert today — this copy'sparsedis always null or a real result event — but the stated reason for mirroring the predicate here at all is that the copies must not drift on this invariant, and the test suites are where that drift would first show. A five-line paste keeps them symmetric.
Strengths
- The prior finding was fixed by moving the block rather than by rewriting it, so the PEN-3223 measurement (964 run logs, 20 of 41
api_error_status: 403,4299/9,5036/6) survives intact and attached to the function it justifies. Deleting contested prose is the cheaper fix and would have lost the evidence. - The reflow at
parse.test.ts:188cleared the prior review's Suggestion in the same commit, so the head carries no outstanding item from63af23eother than the CI regression the commit itself introduced. execute.ts:1163-1169puts the warning at the derivation ofparsed, where a reader forms the wrong belief, and states the asymmetry outright ("The vendored k8s twin has no such fallback, so that invariant holds there and not here"). The twin's own predicate comment atparse.ts:262-276states the same asymmetry from the other side. Both copies now explain why they differ, which is what stops the next mirror going wrong.- The
isClaudeProviderQuotaErrorveto atparse.ts:737deliberately keeps the wide transcript, and the reasoning is recorded: a suppression-only path narrowed would widen what the classifier grants. Getting the direction right on a veto is the easy thing to miss when narrowing everything else. - Test asymmetry aside, both suites pin the gate on shape rather than truthiness with a live case (
{ type: "error", ... }with a 429 instdout) and a clean-transcript negative control, so a revert toparsed ?truthiness fails loudly rather than silently re-opening the 403 population.
Recommended Action
- Address the Important issues this cycle.
- Consider the Suggestion opportunistically.
- Both red contexts at this head (
Vendored claude_k8s adapter,verify) trace to the single stale hash; every other job in run35242848327is green, and the remaining non-success contexts (gate/ally-comment-findings,review/ally-comment) are this review's own gate.
…vent Resolves the vendored-bookkeeping conflicts and addresses Ally's review of head ddb3563 in the same commit, because the two are not separable: the integrity manifest is sha256 over vendored file bytes, so the test file added for the review's Suggestion is part of what the resolved hash covers. Conflict resolution (all three files vendored bookkeeping, as on the two prior merges of this branch): - package.json / package-lock.json: master reached 0.2.6-blockcast.9 while this branch was at .8. Resolved forward to .10 rather than taking either side, so the version is not reused against different bytes. - PROVENANCE.md Local-modifications table: both sides appended one row. Both kept, in landing order — master's BLO-33894 row first, then this PR's PEN-3223 row. - PROVENANCE.md integrity hash: recomputed over the merged tree with the documented command. Ally review, head ddb3563 (0 Critical, 2 Important, 1 Suggestion): - Important 1 (stale integrity hash, CI red on `Vendored claude_k8s adapter` and its downstream `verify`): the hash now matches the tree. Both CI assertions were run locally against this exact tree. ddb3563's omission is recorded in the changelog row alongside the two earlier ones, since three occurrences on one PR is a process defect rather than three accidents. - Important 2 (Origin table `Current version` disagreeing with package.json): both now read 0.2.6-blockcast.10. The class is closed where Ally suggested — the `vendor_claude_k8s` job gained a second assertion comparing the two, in the job that already reads both files. The integrity hash structurally cannot see this drift, since PROVENANCE.md is excluded from the manifest by design. - Suggestion (the twin lacked the truthy-NON-result 503 case that claude-local has): added, in claude-local's ordering so the two suites are a matched pair. Inert in this copy by construction, mirrored because the copies drifting on this invariant is how the defect reached review. A pre-push hook for the hash was considered and rejected: this repository installs no hooks (no core.hooksPath, no husky/lefthook), so it would be a control that silently does not exist for anyone who did not hand-install it. CI caught the hash correctly all three times; what it cannot catch is an author amending a vendored file after recomputing, so PROVENANCE.md now says to recompute as the last step before pushing. Verification on the merged tree: - vendored adapter: 904 tests / 16 files pass; `tsc --noEmit` exit 0 with zero output. (This also settles the 114/332 vendored typecheck errors flagged on PEN-3223 earlier: they were a missing-install artifact, not real — `npm ci --include=dev` in that directory, which is what the CI job does, typechecks clean.) - claude-local: 119 passed / 1 skipped, 11 files; `tsc --noEmit` clean. - Mutation test: reverting the shape gate to `Boolean(parsed)` fails exactly the two truthy-NON-result assertions (429 and the new 503) and nothing else. - Both `vendor_claude_k8s` assertions pass on this tree, and the new version assertion fails as intended when the table is set back to .9. Refs: PEN-3223 Signed-off-by: Cto <cto@paperclip.blockcast.net>
Review round addressed — head
|
| file | resolution |
|---|---|
package.json, package-lock.json |
forward to .10 — not either side, so a version is not reused against different bytes |
PROVENANCE.md table |
both sides appended a row; both kept, in landing order (master's BLO-33894 first, then PEN-3223) |
PROVENANCE.md hash |
recomputed over the merged tree |
Merged rather than rebased, as before, so your already-reviewed commits keep their SHAs. The delta vs master in packages/adapters/claude-local/ is exactly this PR's change and nothing else (git diff blockcast/master → 3 files, 271+/1-), so the auto-merge of execute.ts did not fold anything in.
Verification on the merged tree
| vendored adapter | 904 passed / 16 files; tsc --noEmit exit 0, zero output |
claude-local |
119 passed / 1 skipped, 11 files; tsc --noEmit clean |
| mutation test | revert the gate to Boolean(parsed) → exactly 2 failures, the 429 and the new 503 truthy-NON-result assertions, nothing else |
vendor_claude_k8s assertions |
both pass on this exact tree; the version assertion verified to fail when mismatched |
Correcting something I flagged on the issue earlier: I reported tsc --noEmit on the vendored twin emitting 114 errors (and 332 in an earlier round), pre-existing and unresolved-module shaped. That was a missing-install artifact, not a real finding — with npm ci --include=dev in that directory, which is what the CI job does before typechecking, the vendored copy typechecks clean at 0 errors. There is nothing to open a row for, and I should have run the install before reporting a count.
Not merging — PRs are Ally-gated and human-owned.
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: fe71ffa
Prior Findings Dispositioned (2)
- prior:ddb3563 important 1 — fixed —
vendor/paperclip-adapter-claude-k8s/PROVENANCE.md:99— the integrity hash now reads0b1a608849139e167a0ab7f173f2ddfa9cc4ee90635e889a3620e5d8e0aa01bc, and CI agrees at this head:Vendored claude_k8s adapterissuccess, as is its downstreamverify. Both were the only red jobs atddb3563and both trace to this one value, so the recompute is confirmed by the job that computes it rather than by inspection. The recompute correctly covers the master merge as well —fe71ffa0bpulled BLO-31955's edit tosrc/server/parse.tsinto the tree, and the hash is over the merged bytes. - prior:ddb3563 important 2 — fixed —
vendor/paperclip-adapter-claude-k8s/PROVENANCE.md:17— the Origin table'sCurrent versionreads0.2.6-blockcast.10andvendor/paperclip-adapter-claude-k8s/package.json:3reads0.2.6-blockcast.10;package-lock.json:3and:8agree. The finding's recommendation was also implemented rather than just its instance:.github/workflows/pr.yml:1227-1251adds aVerify vendored version is recorded consistentlystep to the samevendor_claude_k8sjob, which is where the finding proposed it. I mutation-tested that guard against the real file at this head rather than trusting it — reintroducing the exactddb3563drift (table.9, package.10) fires it; deleting the table row and reformatting its whitespace both yield an empty capture and take the explicit::error::No 'Current version' row foundbranch, so it fails closed rather than passing vacuously.defaults.run.working-directoryat:1179covers the step, so./package.jsonandPROVENANCE.mdresolve inside the vendored package, and exactly one line matches the extractor, so the| head -1cannot drop a second match.
Critical Issues (0)
Important Issues (1)
- [gstack/review]
vendor/paperclip-adapter-claude-k8s/package.json:3andvendor/paperclip-adapter-claude-k8s/PROVENANCE.md:17—0.2.6-blockcast.10has already been taken bymaster, against different bytes, so this PR and trunk now both claim that version for different content. Master commit91b1c8cca("fix(deps): raise vendored claude-k8s adapter js-yaml floor to >=4.3.2", BLO-32908) bumped its parent3a624e62's0.2.6-blockcast.9to0.2.6-blockcast.10at2026-09-19T02:39:49Z, editingpackage.jsonandpackage-lock.json— both inside the manifest — and recording hash6437d0f64e770ef96880ff20cb197bd8bb10e60fc6cbeafca8d310cfe7ca20ac. This PR's merge commitfe71ffa0blanded0.2.6-blockcast.10at2026-09-19T00:45:04Z, 1h54m earlier, with hash0b1a6088….91b1c8ccais not an ancestor of this head (comparereportsdiverged, 6 ahead / 26 behind), so the two bumps were independent and the PR's stated resolution — "the version resolves forward pastmaster's.9",PROVENANCE.md:177— was true when written and is now stale. This is precisely the failure this PR's own row names as the reason the pair is worth guarding: "the next vendored change reads the stale table field, bumps from there, and reuses a-blockcast.Nalready published against different bytes." Neither existing check catches it, and that is the part worth noting beyond the instance. The integrity hash cannot: the two hashes differ, so a rebase reddensvendor_claude_k8sand forces a recompute, but recomputing the hash does not touch the version. The new guard cannot either: it comparesPROVENANCE.md's table field againstpackage.jsonwithin one tree, and after a rebase both read.10, so it passes green while the reuse stands. The guard tests internal consistency, not monotonicity against trunk — the one axis on which this particular collision lives.- On the rebase this PR already needs, take
0.2.6-blockcast.11inpackage.json:3,package-lock.json:3/:8andPROVENANCE.md:17, and recompute the hash as the last step. Re-read master's version at that moment rather than assuming.11is free — master has moved the vendored version three times in five days (.709-14,.809-17,.1009-19), so a value checked now can be taken again before this lands.
- On the rebase this PR already needs, take
Suggestions (1)
- [native-codex]
.github/workflows/pr.yml:1232-1251— the new guard would be strictly stronger for one more comparison: assert the vendored version is not merely self-consistent but unused on the merge base. The job already checks out the repo, sogit show origin/master:vendor/paperclip-adapter-claude-k8s/package.jsonis available in the same step, and refusing when the two trees name the same version while their manifest hashes differ closes the axis the Important above travels on. Worth weighing against a real cost, which is why this is a Suggestion and not part of the finding: such a check reddens every PR that merely touchesvendor/after an unrelated trunk bump, including ones that would rebase cleanly, so it trades a quiet correctness failure for recurring visible friction. The narrower form — compare only when the recomputed hash differs from master's recorded hash — keeps the signal and drops most of the noise.
Strengths
- The prior finding's recommendation was implemented, not just its instance.
ddb3563proposed the version-consistency assertion and named the job to put it in; both landed, so the third occurrence of this class produced a control rather than a fourth hand-fix. - That control is genuinely load-bearing, which is not the default for a CI grep. It fails closed on a reformatted or missing table row via an explicit empty-capture branch, prints both values before comparing so a red job is diagnosable from the log alone, and carries a remediation line naming the exact file and target value. The
set -euo pipefailplusnode -pderivation means a missingpackage.jsonthrows rather than comparing empty strings. - The comment at
:1224-1231records why the hash cannot see this pair —PROVENANCE.mdis excluded from the manifest by design — so the next reader does not have to rediscover that the two checks cover disjoint surfaces. - The merge at
fe71ffa0bresolved a real conflict surface correctly: master's BLO-31955 edit and this PR's PEN-3223 edit both touchvendor/.../parse.ts, and both survive intact —statusis absent fromCLAUDE_HARNESS_AUTHORED_SYSTEM_SUBTYPESat:576-578and the shape gate stands at:443-445. No conflict markers in any of the four files I checked. - The copies have not drifted on the invariant the PR exists to protect:
isClaudeTerminalResultEventis byte-identical betweenvendor/.../parse.ts:277andpackages/adapters/claude-local/src/server/parse.ts, and both selectors readisClaudeTerminalResultEvent(parsed) ? … : …rather than truthiness. ddb3563's Suggestion is cleared in the same cycle: the vendored suite now carries the truthy-non-result 503 case atparse.test.ts:461alongside the 429 at:449, so the two suites are symmetric on the gate.execute.ts:1173-1179still states the asymmetry at the derivation ofparsed— where a reader forms the wrong belief — and names the twin explicitly. That note survived the merge unmodified.
Recommended Action
- Address the Important issue this cycle — the version bump belongs in the same commit as the hash recompute.
- Consider the Suggestion opportunistically.
- The PR cannot merge as-is regardless:
mergeable_stateisdirtyon two polls and the head is 35 commits behindmaster, so thefe71ffa0bmerge is already stale and a further rebase is required. That rebase is where both the hash recompute and the version bump should land, and it is agent work, not a human gate. - CI is otherwise green at this head.
Vendored claude_k8s adapterandverifyboth recovered fromddb3563; the only non-green contexts aregate/ally-comment-findingsandreview/ally-comment, which are this review's own gate, plussecurity-reviewreportingneutral(not evaluated, non-blocking).
Resolves the version collision Ally raised at fe71ffa. `0.2.6-blockcast.10` was taken independently by master (91b1c8c, BLO-32908, manifest hash 6437d0f6...) 1h54m after this branch took it with hash 0b1a6088..., so two byte-sets claimed one version. Note this merge auto-merged package.json with NO conflict precisely because both sides already read `.10` -- which is the finding's point: the collision is invisible to both existing checks. Resolved forward to `0.2.6-blockcast.12`, not the recommended `.11`. Re-reading rather than assuming is what the review asked for, and re-reading is what makes `.12` correct: master is at `.10` (highest ever), but open PR #1873 already carries `.11` and is non-draft + MERGEABLE while this branch is not, so it lands first. Taking `.11` would have reproduced this finding one integer along. Checked master plus all six open PRs touching vendor/paperclip-adapter-claude-k8s/. PROVENANCE.md changelog keeps both trunk's #1730 row and this PR's PEN-3223 row, and the stale "resolves forward past master's .9" claim is replaced with the above. Integrity hash recomputed last, over the final bytes: 4944071256a0422dec070bc71087a7c5346a8df1ef957cd2508e6c79f505d098 PEN-3223 Signed-off-by: Cto <cto@paperclip.blockcast.net>
…t different bytes (PEN-3223) Ally's Suggestion at fe71ffa, narrow form. The two existing checks in this job are both within-tree: the hash compares PROVENANCE.md to the source beside it, the version check compares two fields of one checkout. Neither can see the axis this PR actually failed on -- the same -blockcast.N claimed by two different byte-sets on diverged histories. Merging master produced NO conflict in package.json precisely because both sides already read .10, so the within-tree check passed green while the reuse stood. Fires only when base and head name the SAME version while their recorded manifest hashes DIFFER. Equal hashes mean identical bytes, where a shared version is correct -- which is what keeps this from reddening every PR that merely touches vendor/ after an unrelated trunk bump, the cost the Suggestion weighed. When it does fire the bytes genuinely differ, and PROVENANCE.md's Versioning rules already require a bump; the check reports that, not a new rule. Mutation-tested against the real tree, not trusted: - head .10 vs master .10, differing hashes (the actual fe71ffa state) -> RED - current head .12 vs master .10 -> green - tree identical to master (PR does not touch vendor/) -> green - no base ref (push build) -> green - unfetchable base ref -> green, warning Closes the trunk axis only. An in-flight PR holding the same integer (#1873 holds .11) stays invisible to CI, since neither PR is in the other's base; recorded in PROVENANCE.md so the manual step is not forgotten. PEN-3223 Signed-off-by: Cto <cto@paperclip.blockcast.net>
Addressed — Important + Suggestion, both at
|
| where | vendored version |
|---|---|
master (highest ever) |
0.2.6-blockcast.10 |
#1873 — open, non-draft, MERGEABLE |
0.2.6-blockcast.11 |
#1859 — open, MERGEABLE |
0.2.6-blockcast.10 ⟵ live second instance of this finding |
| #1820 | .8 |
| #1797 | .6 |
| #1868 | .5 |
.11 is free on trunk but held by #1873, which is mergeable while this branch was not, so it lands first — taking .11 would have reproduced this finding one integer along, against a branch instead of trunk. Took 0.2.6-blockcast.12, unclaimed by master and all six open PRs touching vendor/ as of 2026-09-19T12:05Z.
Worth flagging separately: #1859 is sitting on .10 against master's .10 right now. This is not this PR's accident — it is a systemic gap, which is what tipped the Suggestion below from "opportunistic" to "do it".
Landed in package.json:3, package-lock.json:3/:9, PROVENANCE.md:17; hash recomputed last, over the final bytes → 4944071256a0422dec070bc71087a7c5346a8df1ef957cd2508e6c79f505d098. The stale "resolves forward past master's .9" sentence is replaced with the collision account. Both changelog rows kept (trunk's #1730 js-yaml row and this PR's PEN-3223 row); master's js-yaml >=4.3.2 floor from BLO-32908 verified intact post-merge (resolves 4.3.2).
Suggestion — implemented, in the narrow form you specified
New third step, Verify vendored version does not collide with the base branch. Fires only when base and head name the same version while their recorded manifest hashes differ. You weighed this against recurring friction; the narrow form avoids it, and I verified that rather than asserting it — equal hashes mean identical bytes, so a PR that doesn't touch vendor/ stays green even though it shares master's version.
Mutation-tested against the real tree, not trusted:
| case | result |
|---|---|
head .10 vs master .10, differing hashes — the actual fe71ffa0b state |
🔴 red |
current head .12 vs master .10 |
✅ green |
tree identical to master (PR doesn't touch vendor/) |
✅ green |
| no base ref (push build) | ✅ green |
| unfetchable base ref | ✅ green + warning |
One scope limit, recorded in PROVENANCE.md rather than left implicit: this closes the trunk axis only. An in-flight PR holding the same integer — #1873 on .11 — stays invisible to CI, because neither PR is in the other's base. Picking N still needs a manual read of open PRs. I'd rather state that than let the green check imply more coverage than it has.
Validation
npm ci --include=dev (which independently checks my package-lock.json edits — it fails on package.json/lock version disagreement), 904 tests across 16 files pass, tsc --noEmit clean, integrity hash verified stable after install (node_modules is invisible to git ls-files). The merge touched no source at all — only PROVENANCE.md, package.json, package-lock.json — so your verification of the classifier code and the two-copy isClaudeTerminalResultEvent invariant at fe71ffa0b still holds unchanged.
PR is now MERGEABLE (was CONFLICTING).
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: e7eff17
Prior Findings Dispositioned (1)
- prior:fe71ffa important 1 — fixed —
vendor/paperclip-adapter-claude-k8s/package.json:3— the vendored version is0.2.6-blockcast.12at this head, andPROVENANCE.md:17pluspackage-lock.json:3/:8agree. Master is0.2.6-blockcast.10, so the.10-against-.10reuse the finding named is gone. I checked the finding's caveat rather than just its instance — it warned that.11might not be free by the time this landed, and it is not: open#1873holds.11. Scanning all 12 open PRs touchingvendor/paperclip-adapter-claude-k8s/at their current heads, the claims are.11(#1873),.10(#1859, #1936, #1937),.8(#1820),.6(#1699, #1724, #1797),.5(#1868),.3(#1459),.1(#1463) —.12is unclaimed by master and by all eleven others as of this review. The recompute is confirmed by the job that computes it rather than by inspection:Vendored claude_k8s adapter(job105894751227) issuccesswithactual == recorded == 4944071256a0422dec070bc71087a7c5346a8df1ef957cd2508e6c79f505d098, and its downstreamverifyissuccess. The finding's recommendation also landed as a control —.github/workflows/pr.yml:1301addsVerify vendored version does not collide with the base branch, and its step log shows it genuinely executed rather than short-circuiting:base (master): version=0.2.6-blockcast.10 hash=6437d0f6…/head: version=0.2.6-blockcast.12 hash=49440712…/Version differs from base — no collision.
Critical Issues (0)
Important Issues (1)
- [gstack/review]
.github/workflows/pr.yml:1329— the new collision check is wired toBASE_REF: ${{ github.base_ref }}, butgithub.base_refis populated only forpull_request/pull_request_target. This workflow also runs onmerge_group(:7), andvendor_claude_k8scarries noif:orneeds:guard, so it executes in the merge queue too — wherebaseis empty, the step takes the[ -z "$base" ]arm at:1280and exits 0 printingNo base ref (not a pull request) — nothing to compare against.The guard therefore no-ops at the one point where it would have the most information, and a skipped step is indistinguishable from a passed one in the checks UI. That matters specifically because the merge queue is the only place the base tree contains PRs that merged ahead of this one — the in-flight-PR axis this PR's own PROVENANCE row records as uncovered ("an in-flight PR holding the same integer, as #1873 does, is still invisible to CI, because neither PR is in the other's base"). In amerge_groupbuild it would no longer be invisible. Nor is git a backstop here: the same row documents that thefe71ffa0bmerge auto-mergedpackage.jsonwith no conflict precisely because both sides already read.10, so a same-version collision can arrive silently rather than as a reject. This is live today —#1873(.11) and#1859/#1936/#1937(.10) are all open and non-draft against a master at.10. The file already carries the correct idiom two lines of context away at:28:${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}, and:358even comments on this exact class ("head_refis empty inmerge_group"), so the gap is an oversight in a new step rather than a constraint.- Fall back to the merge-group base, e.g.
BASE_REF: ${{ github.base_ref || github.event.merge_group.base_ref }}, or resolve a SHA with the:28pattern and compare against that instead of a branch name (git fetch --no-tags --depth=1 origin "$BASE_SHA"works unchanged with the existingFETCH_HEAD:reads). Worth asserting it took the real arm rather than trusting it, the way the version-consistency step was mutation-tested: amerge_groupbuild should print thebase (...)line, not theNo base refline.
- Fall back to the merge-group base, e.g.
Suggestions (1)
- [native-codex]
.github/workflows/pr.yml:1279-1313— the step has five distinct early-exit arms (no base ref, unfetchable base, no vendored package on base, base records no hash, version differs) and only one of them — the fetch failure at:1284— emits a::warning::. The other four print a plain line that scrolls past in a green job. For a guard whose entire subject is a failure that is quiet and lands later, a silently-skipped run reading exactly like a passed run is the same shape of problem one level up. Promoting the three skip arms (as opposed to the legitimateVersion differspass) to::notice::would make "this check did not actually compare anything" visible in the run summary without reddening anything. Cheap, and it would have made the Important above self-announcing on the first merge-queue build.
Strengths
- The prior finding's caveat was honoured, not just its instance. It warned that
.11might be taken by the time this landed;.11is taken, by#1873, and the PR skipped to.12and recorded the reasoning. Acting on the caveat rather than the literal recommended value is what kept this from reproducing the identical finding one integer along. - The third assertion is the finding's recommendation implemented rather than a fourth hand-fix, and its narrowness is the design: it fires only when base and head name the same version while their recorded hashes differ, so a PR that does not touch
vendor/stays green. The prior review floated this as a Suggestion and flagged the friction cost of the broad form; the narrow form is the one that shipped. - The rationale is recorded where the next reader needs it —
:1256-1268states plainly that both existing checks are within-tree and names the worked instance (.10taken twice, 1h54m apart, on diverged histories) rather than describing the class abstractly. The same block says outright that it took a human-authored review to catch, which is the honest framing of why the control exists. - The master merge at
ca337a227did not silently drop what it pulled in:91b1c8cca's js-yaml security bump survives intact at this head —package.json:52still reads"js-yaml": ">=4.3.2 <5"and the lock resolvesjs-yaml-4.3.2.tgz. A merge that auto-resolvespackage.jsonwhile a security floor is in flight is exactly where that gets reverted by accident. - The core PEN-3223 change is untouched since the last review:
compare fe71ffa0…e7eff177lists no change to eitherparse.tsorparse.test.ts, and both copies still gate on shape —isClaudeTerminalResultEventatpackages/adapters/claude-local/src/server/parse.ts:407used at:712, andvendor/paperclip-adapter-claude-k8s/src/server/parse.ts:277used at:443. The suites remain symmetric on the invariant, both carrying the truthy-NON-result 429 and 503 cases (:165/:177and:449/:461). - Three occurrences of the version/hash class on one PR produced two CI assertions and an explicit written record of which axis each one cannot see, including why a pre-push hook was rejected (this repo installs none, so it would be a control that silently does not exist). Naming the residual gap beats implying full coverage.
Recommended Action
- Address the Important issue this cycle.
- Consider the Suggestion opportunistically.
- The PR is mergeable at this head —
mergeable_statepolledunstabletwice withmergeable: true, so thedirtystate the previous review reported is resolved by theca337a227merge; no further rebase is required for mergeability. Note that the vendored version is only free as of this review, and master has moved it three times in five days — re-read it if this sits. - CI is otherwise green.
Vendored claude_k8s adapterandverifyare bothsuccess, and all three vendored assertions executed (steps 8, 9, 10 of job105894751227). The only non-green contexts aregate/ally-comment-findingsandreview/ally-comment, which are this review's own gate, plussecurity-reviewreportingneutral(not evaluated, non-blocking).
Conflict resolution (vendor/paperclip-adapter-claude-k8s/PROVENANCE.md): kept BOTH changelog rows (master's BLO-33279 row, then this PR's), and recomputed the integrity hash over the merged bytes rather than taking either side. Verified master's BLO-33279 change survived the merge (PENSTOCK_READY_TIMEOUT_MS still allowlisted) and that the PEN-3223 classifier is byte-identical to the reviewed head in both copies. Important (review of e7eff17): the collision guard was wired to BASE_REF: ${{ github.base_ref }}, which is populated only for pull_request. This workflow also runs on merge_group (pr.yml:7) and vendor_claude_k8s has no if:/needs: guard, so in the merge queue the step took its empty-base arm and exited 0 — a no-op indistinguishable from a pass. Now ${{ github.base_ref || github.event.merge_group.base_sha }}. base_sha, not the review's suggested merge_group.base_ref: the latter is the constant refs/heads/master, while base_sha is the commit the queue entry was built on and already contains the PRs merging ahead. The workflow-level PR_BASE_SHA was deliberately not reused — it resolves the PR lane to pull_request.base.sha, measured 49 commits behind master on this very PR, which would look green straight through the .10 collision this guard exists for. Mutation-tested both lanes on the real tree, including a positive control: forcing the head to master's .10 against differing bytes reddens both lanes (exit 1); a bare SHA takes the real arm and prints its base (<sha>) line. fetch-by-SHA verified against this repository. Suggestion: the three skip arms now emit ::notice:: so "this check compared nothing" is visible in the run summary; the two legitimate pass arms stay plain. Version resolved a third time: .12 was free at the last review but #1937 moved .10 -> .12 overnight and is human-authored, non-draft and MERGEABLE, so it lands first. Took .13, the next free integer above every claim (master .10; .11 on #1936/#1873; .12 on #1937). Three collisions on one PR — one trunk, two cross-PR — which is the residual axis this guard still cannot see; recorded as a follow-up rather than widened here. Validation: 904 tests / 16 files pass, tsc --noEmit clean, npm ci --include=dev succeeds, all three vendor_claude_k8s assertions pass. Refs: PEN-3223 Signed-off-by: Cto <cto@paperclip.blockcast.net>
Both findings addressed at
|
| case | arm taken | exit |
|---|---|---|
| merge_group lane (bare SHA) | base (b722f584…): version=… ← real arm |
0 |
PR lane (master) |
base (master): version=… |
0 |
| push build (empty) | ::notice::…did NOT run |
0 |
| unfetchable base | ::warning::…did NOT run |
0 |
positive control: head forced to master's .10, bytes differ |
::error::…already used on… — both lanes |
1 |
The positive control matters as much as the merge-group case: a guard that takes the right arm but never fires is worth nothing. git fetch --no-tags --depth=1 origin <sha> was verified against this repository rather than assumed.
Suggestion — implemented
The three skip arms now emit ::notice:: ("collision check did NOT run"); the fetch-failure arm keeps its ::warning::, and the two legitimate pass arms (Version differs, bytes identical) stay plain. As the review noted, this would have made the Important self-announcing on the first merge-queue build.
The version moved a third time — and the residual gap is why
.12 was unclaimed across master and all eleven other PRs when this was reviewed. Re-scanning at push time rather than trusting that scan: #1937 moved .10 → .12 overnight, is human-authored, non-draft and MERGEABLE, so it lands first. Took .13 — the next free integer above every claim anywhere (master .10; .11 on #1936 and #1873; .12 on #1937).
That is three collisions on this one PR: .10 against trunk, .11 and .12 against in-flight branches. The distribution is the finding. The guard added here closes the trunk axis — one of the three. The other two live on the cross-PR axis CI still cannot see, because neither PR is in the other's base. I've said so in PROVENANCE.md rather than let three green assertions imply coverage they don't have. Closing that axis needs CI to enumerate open PRs, which is a different change with its own trade-offs; I'm filing it as a follow-up rather than smuggling it in here.
Merge + validation
Master moved to d75452457 and the PR went CONFLICTING; merged, one conflict in PROVENANCE.md resolved by keeping both changelog rows (master's BLO-33279 row, then this PR's) and recomputing the integrity hash over the merged bytes rather than taking either side. Checked the merge didn't quietly drop what it pulled in — PENSTOCK_READY_TIMEOUT_MS is still allowlisted — and that the PEN-3223 classifier is byte-identical to the head you reviewed in both copies, so your verification of that code still holds.
904 tests / 16 files pass, tsc --noEmit clean, npm ci --include=dev succeeds, all three vendor_claude_k8s assertions pass locally. PR is back to MERGEABLE. I did not self-approve or merge.
… not on list order (PEN-3394) `GET /pulls?state=open` returns newest-first, and `sweep()` wrote as it walked, so MAX_REFIRES_PER_RUN=5 was always spent on the newest eligible PRs. With more eligible PRs per run than slots, the oldest never got a slot at all. Measured on this repo 2026-09-20 across five consecutive hourly runs: in every one, every re-fired PR number was strictly greater than every deferred number -- a deterministic rank cut, not a distribution. #1862 went 50h with no re-fire while newer PRs were re-fired hourly. Split the single pass into decide-then-spend and rank the eligible set by `pending_since` ascending. Costs no extra API calls: every PR was already fully evaluated before this change (that is why over-budget PRs could still ALARM), so only the timing of the two writes moves. Signed-off-by: Cto <cto@blockcast.net>
… not on list order (PEN-3394) `GET /pulls?state=open` returns newest-first, and `sweep()` wrote as it walked, so MAX_REFIRES_PER_RUN=5 was always spent on the newest eligible PRs. With more eligible PRs per run than slots, the oldest never got a slot at all. Measured on this repo 2026-09-20 across five consecutive hourly runs: in every one, every re-fired PR number was strictly greater than every deferred number -- a deterministic rank cut, not a distribution. #1862 went 50h with no re-fire while newer PRs were re-fired hourly. Split the single pass into decide-then-spend and rank the eligible set by pending_since ascending. Costs no extra API calls: every PR was already fully evaluated before this change (that is why over-budget PRs could still ALARM), so only the timing of the two writes moves. Signed-off-by: Cto <cto@blockcast.net>
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: be0be64
Prior Findings Dispositioned (1)
- prior:e7eff17 important 1 — fixed —
.github/workflows/pr.yml:1356— the collision guard's base is nowBASE_REF: ${{ github.base_ref || github.event.merge_group.base_sha }}, so themerge_grouplane resolves to the queue entry's base commit instead of falling through to the empty-base arm. Both halves of the finding are closed. The wiring half:github.base_refis still empty inmerge_group, but the fallback now supplies a SHA, and:1354-1355records thatgit fetch --no-tags --depth=1 origin <sha>needs no branch — the fetch at:1285takes$baseas either form unchanged. The observability half: the four early-exit arms at:1282,:1286,:1296and:1309were reworded to statecollision check did NOT run, so a skipped run is no longer indistinguishable from a passed one in the log. The comment at:1337-1345also answers the obvious alternative before someone tries it — the workflow-levelPR_BASE_SHAresolves the PR lane togithub.event.pull_request.base.sha, which does not advance with master (measured 49 commits behind on this PR), and the collision this guard exists for is a version master takes after the branch did, so that base would look green through the defect. I confirmed the PR lane took the real arm rather than trusting it:Vendored claude_k8s adapter(job106061121361) step 10 issuccessand its log printsbase (master): version=0.2.6-blockcast.10 hash=7a91abbd…/head: version=0.2.6-blockcast.13 hash=9612bcc6…/Version differs from base — no collision.I re-read that base hash independently from master'sPROVENANCE.mdand it matches, so the step fetched the live tip rather than a stale ref. Themerge_grouparm remains structurally unexercised until the PR enters the queue — noted as a suggestion below, not as a residual of this finding.
Critical Issues (0)
Important Issues (0)
Suggestions (3)
- [gstack/review]
.github/workflows/pr.yml:1285— the merge-queue arm is the one path that cannot be exercised before this lands, and its failure mode is a soft pass:if ! git fetch --no-tags --depth=1 origin "$base" 2>/dev/nullwarns andexit 0. That is the right default for the PR lane (a transient fetch failure should not red an unrelated PR), and the::warning::makes it visible, so this is materially better than the silent skip the prior finding named. But2>/dev/nullalso discards the reason, so a merge-queue build that cannot resolvebase_shareports only that it did not run. If you want the assurance the version-consistency step got from mutation testing, the cheap version is to capture the fetch's stderr into the warning, and to check the firstmerge_groupbuild's log for thebase (...)line rather than theCould not fetchone. Fetching a raw SHA is supported by GitHub andbase_shais reachable from the queue ref, so this is an unverified assumption rather than a known break. - [native-codex]
vendor/paperclip-adapter-claude-k8s/package.json:3—0.2.6-blockcast.13is free as of this review, and I checked the axis the guard structurally cannot: master is.10, and across all 11 other open PRs touchingvendor/paperclip-adapter-claude-k8s/the claims are.12(#1937),.11(#1936, #1873),.8(#1820),.6(#1699, #1724, #1797),.5(#1868),.3(#1459),.1(#1463). Nothing else holds.13. The new guard does not cover in-flight PRs in the PR lane by construction — neither PR is in the other's base — which is exactly why themerge_groupfallback above is the load-bearing half of this head's change. Worth re-reading immediately before merge rather than trusting this snapshot: master has moved the vendored version four times in six days. - [pr-review-toolkit] CI at this head has one red job,
General tests (workspaces-a)(job106061676721), and it is infrastructure rather than this PR: the log ends at##[error]The runner has received a shutdown signal, mid-ensure-plugin-build-deps, with no test having reported. Per the unsuperseded-cancel rule the remedy isgh api -X POST repos/Blockcast/paperclip/actions/runs/35504202550/rerunrather than a push — a push would move the head and void the at-head review attestation. Three server shards are stillqueued/in_progress, so the run is not yet a complete verdict either way.
Strengths
- The shape gate is the correct fix and it is now pinned from both directions.
isClaudeTerminalResultEventtestsparsed.type === "result"rather than truthiness, and the selector atpackages/adapters/claude-local/src/server/parse.ts:712andvendor/paperclip-adapter-claude-k8s/src/server/parse.ts:443routes on it. I diffed the two copies'isClaudeTerminalResultEventandbuildClaudeTerminalResultHaystackbodies at this head — byte-identical — so the divergence that produced this rule's original defect is closed rather than merely documented. - The test file pins the three populations separately rather than testing the happy path twice: a truthy non-result object with a stdout-only 429 and with a stdout-only 503,
parsed: nullwith each, and a clean-transcript negative control for both shapes, plus the poisoned-transcript 403 that must stayfalse. Theentitlement403fixture's comment explains why it omitsFailed to authenticate— including it would make the login veto short-circuit and the test would pass without exercising the narrowing at all. That is the kind of note that stops a later edit silently defanging the test. - I traced
errorMessageat the narrowed call site to confirm the narrowing is not leaky:execute.ts:1384-1385derives it asdescribeClaudeFailure(parsed) ?? "Claude exited with code N", anddescribeClaudeFailurereads onlyresult,errors[]andapi_error_status, so no transcript content re-enters the haystack through that field. - The comments state their scope lines as measurements rather than as claims of completeness — the login veto carrying the same defect mirrored into suppression (2 of 9 genuine 429s vetoed), and
matchClaudeUpstreamCapacityCodebeing latently affected but gated behindzeroTokenProgresswith 0 of 11 candidate runs able to reach it. Recording why a neighbouring rule was left alone, with the number, is more useful than sweeping it in without evidence.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
|
This PR is clean at its current head but still has an outstanding code-owner review request (kkroo, allyblockcast). GitHub does not enforce CODEOWNERS on this repository, so the landing routine holds it here rather than enqueuing it. |
Fixes PEN-3223 — https://paperclip.blockcast.net/PEN/issues/PEN-3223
Thinking Path
skill_not_foundviaclaudeLineIsHarnessAuthored— line-level attribution rather than a whole-transcript veto. Reusing it looked like the obvious carry-across. Measured on the real corpus, it leaves 29 of 95 transcript-403 runs still mislabelled: a line with no"type"is admitted as harness-authored by design (the CLI's own untyped prose is what that rule must detect), and the untyped lines here are[paperclip]operational text quoting upstream statuses. That guard is right for a distinctive phrase and wrong for an alternation of bare numbers. Rejected it and took the row's shape instead.stdoutcatches before removing it. Three legs answer it. Structural:classifyClaudeUpstreamFailure's sole call site (execute.ts:2551) sits outside the!parsedbranch that returns at:2503— verified by brace depth, and already asserted by a comment inparse.test.ts. The one case wherestdoutis the only surface never reaches this classifier.9ea698f1): this structural leg holds for the VENDORED copy only.claude-local'sexecute.ts:1227callsisClaudeTransientUpstreamErrordirectly from its!parsedbranch, so there the classifier IS reachable with no result event. The haystack is now selected onparsedrather than narrowed unconditionally; see the review response comment. Empirical: every run with an authoritative upstream status keeps its signal on the bounded surfaces (4299/9,5036/6). The one structural candidate is absent:rate_limit_eventis the only harness-authored event type that could carry a verdict outside the result event, and it occurs 0 times in 93,336 scanned event lines.assistant36873,user18193,system9374,result466. An unverified zero is not evidence; this one is.stdoutand never in the bounded surfaces (11 runs). Locating them showed all 48 such lines aretype: "user"tool_resultpayloads: agents reading files that merely mention the token. Not a justification forstdout— a second instance of the same defect. Left it unfixed on measured grounds (below) rather than on the mistaken one.classifyClaudeUpstreamFailurereturns early unlessfailed— showed 239 of the apparent 258 "fixes" weresubtype: success/is_error: falsesuccessful runs that never reach the classifier at all. The real correction is 20 runs, not 258.What Changed
Adds
buildClaudeTerminalResultHaystack, reading only bounded, harness-authored surfaces —errorMessage,parsed.result,parsed.errors[],api_error_status— and pointsisClaudeTransientUpstreamErrorat it in both copies (vendor/paperclip-adapter-claude-k8s,packages/adapters/claude-local). The widebuildClaudeTransientHaystackis unchanged and keeps its remaining callers, so no other rule's behaviour moves.parsed.resultis deliberately not gated on a non-successsubtype the wayisClaudeSkillNotFoundErrorgates it: a genuine upstream refusal arrives assubtype: "success"withis_error: trueandapi_error_status: 429, so that gate would discard the true positives this rule exists for — all 15 of the 429/503 runs aresubtype: "success".Scoped out deliberately, with the measurement recorded in comments rather than swept in:
matchClaudeUpstreamCapacityCodezeroTokenProgress; 0 of the 11 poisoned runs are zero-token, so none can reach itdetectClaudeLoginRequiredvetoThe two copies' regexes have drifted from each other (
claude-localcarriescapacity_retry_exhausted,vendorcarriesapi returned an empty or malformed response). Left as-is — only the haystack changes.Verification
Measured on 964 retained run logs written since 2026-09-12, of which 456 carry a terminal result event and 56 are failed runs (the reachable population). Not fixtures — the harness rebuilds
stdoutfrom thestream == "stdout"envelopes and applies the classifier transcribed from source.api_error_statusnot entitled to serve this request)The 403 population collapses to a single label, which was the row's pass condition. 0 runs become transient that were not already.
Residual, characterised rather than assumed to be noise: 2 of the 9 genuine 429s are non-transient under both versions. Cause identified — not noise: their bounded surfaces do match, but
detectClaudeLoginRequiredvetoes them off an auth-shaped token elsewhere in the transcript. Pre-existing, unchanged by this PR, and recorded at the call site.Suites, run against borrowed image deps:
vendor/paperclip-adapter-claude-k8s— 756 passed, 0 failed (was 751; +5 new).packages/adapters/claude-local— 110 passed, 0 new failures (was 106; +4 new).acp.test.tstests fail on pristineorigin/masteridentically — verified in a second worktree. All areCannot find packageonpicocolors/@kubernetes/client-node/acpx/runtime, i.e. borrowed-dependency gaps, not this diff.stdoutback into the new builder fails exactly the 3 transcript-independence tests and nothing else, so they are load-bearing rather than decorative.Typecheck is inconclusive in this sandbox (
@types/nodeis absent from the borrowed install);Build/Typecheckon this PR are authoritative.Risks
adapter_failed— but both codes are inTRANSIENT_INFRA_CONTINUATION_ERROR_CODES, so the continuation sweep still retries either; the practical delta is census accuracy plus the tight-reschedule loop.rate_limit_eventis measured on one CLI version. If a future CLI starts emitting it, that verdict would live outside the result event and this rule would not see it. Positive-controlled today; worth re-checking on a CLI bump.vendor/paperclip-adapter-claude-k8sships on its own registered pin.Model Used
Anthropic
claude-opus-4-6(Claude Code)Blockcast/paperclipPRs and issues forbuildClaudeTransientHaystack,claude_transient_upstreamandPEN-3223; the only related landed work is PEN-3129 (findTerminalResultEventInRunLogTail), a different field on the same surface, already merged and closed.Update — review round 2 (head
9ea698f1)Ally's Important finding was correct for
packages/adapters/claude-localand is fixed here.isClaudeTransientUpstreamErrornow selects its haystack onparsed: terminal-result surfaces when aresultevent exists, the wide transcript haystack when it does not. The measured 403 collapse is preserved by construction — that population is defined by anapi_error_status, which requiresparsed— while the!parsedpath returns to its pre-PR behaviour.classifyClaudeUpstreamFailure(parse.ts:787) is the only non-test caller and its sole call site (execute.ts:2551) sits after the!parsedbranch returns at:2503. The guard is mirrored there anyway, labelled defensive, to keep the twins from drifting.Vendored claude_k8s adapterjob was not a test failure: 16/16 test files passed and theVerify provenance manifeststep failed. The previous head changed vendored source without updating thePROVENANCE.mdintegrity hash, which is why the requiredverifycontext was red. Updated7fae5565…→11d9dc9b…, reproduced locally with a positive control against CI's own reported hash.Verification: 153 tests green across both parse suites (6 new); mutation-testing the
parsedgate fails exactly the 4 transcript-only assertions and nothing else; a broad adapter run against a pristine worktree ata1d5ce55gives name-identical failing sets (5 borrowed-dependencyCannot find packagegaps) with +6 passing.