fix(recovery): adapter-aware stale-run remedy, and stop gating evidence on logBytes (PEN-2106) - #1481
Conversation
…ce on logBytes (PEN-2106) The stale_active_run_evaluation path treats every silent run as if it had an external lifecycle. Two consequences, both fixed here. 1. The reopen remedy and the re-fire suppression note both assert the BLO-4467 wedge unconditionally: "the pod/Job is gone, force-finish the run so the agent's concurrency lock releases". On a sessioned-local adapter (claude_local, codex_local) there is no pod, no Job, and no lock -- a run silent past RUN_STALE_SILENCE_MS is already excluded from runningCount, so it holds no slot. The text sent readers after a pod that does not exist and invited a cancel to free a lock nothing held. Both strings now render through one adapter-aware helper, and the sessioned-local branch names the real constraint: the only route to terminal is the board-gated cancel. 2. readRunLogTailForEvidence gated on run.logBytes. logStore/logRef are written right after runLogStore.begin(), but logBytes is only written back on finalize -- so it is null/0 for the entire population this detector fires on, and the card's evidence block was unconditionally empty for every stale-run wrapper ever minted. logBytes is now a seek hint (the store already stat()s and clamps the range), with a bounded forward walk that holds the trailing window when the hint is missing or stale-low. Signed-off-by: Search <search@example.com>
1 similar comment
|
Hey @allyblockcast[bot]! 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 |
… (PEN-2106)
Adds four regression tests for the adapter-aware stale-run remedy, and
fixes two defects the tests exposed in the previous commit.
Tests, under the existing active-run output watchdog suite:
- a sessioned-local (`claude_local`) reopen comment contains none of
`pod` / `Job` / `concurrency lock`, and does name the grant boundary
- an external-lifecycle (`claude_k8s`) reopen comment still carries the
BLO-4467 pod/Job/reaper remedy verbatim, so the branch cannot
silently blank both sides
- the suppression note branches its mechanism phrase the same way, on
both adapter classes
- the evidence tail is collected for a still-`running` row whose
`logBytes` is 0, which is the detector's entire population
The seed helper gained `agentAdapterType` and `staleLogBytes`. The
latter reproduces production: `logBytes` is only written back on
finalize, so a `running` row carries 0, while the helper previously
always wrote the real byte count -- which is why no existing test caught
the evidence reader being gated on it.
Two fixes the tests caught:
- `EXTERNAL_LIFECYCLE_ADAPTER_TYPES` was imported from
`@paperclipai/db`, which does not export it. It lives in
`packages/shared/src/validators/agent.ts`; the import now matches how
`heartbeat.ts` and `services/agents.ts` resolve it.
- the sessioned-local remedy text still said "NOT the BLO-4467 pod/Job
wedge" and "no lock is being held" -- i.e. it still put the reader on
a pod and a lock while denying them. Reworded to "no external runtime
lifecycle", "no external workload to force-finish", and "frees no
capacity", which is what the ticket's own predicate asks for.
Refs PEN-2106
Signed-off-by: Search <search@example.com>
|
Hey @allyblockcast[bot]! 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 |
|
Marked ready for review. Head unchanged at Heads-up on the red Failing step is #8 This PR touches only The assertion looks like a latent repo-wide flake rather than a real precision bug. It is a precondition guard checking that the DB-default Not bundling that here — unrelated file, and I would rather not move this head. Flagging it so the red is not read as a finding against this change. |
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: 956ebfe
Critical Issues (0)
Important Issues (0)
Suggestions (1)
- [native-codex]
server/src/services/recovery/service.ts:2953— consider adding a focused test for a multibyte UTF-8 log crossing a chunk boundary, since the reader advances in byte offsets while the returned content is decoded to strings.
Strengths
- The adapter-specific remediation is centralized and preserves the external-lifecycle behavior while avoiding false pod/Job guidance for local adapters.
- The evidence reader now handles the still-running population where
logBytesis unset, uses the store's forward offsets, bounds scanning, and preserves partial evidence on read failure. - The added tests include both adapter directions and a fail-first regression for the previously blind log-tail path.
Recommended Action
- No Critical or Important issues found; the PR is suitable for merge after CI completes.
- Consider the UTF-8 boundary regression test opportunistically.
Thinking Path
Linked Issues or Issue Description
Refs PEN-2106 (Penstock-side record of the observation), BLO-4467 (completed-run-not-reaped), BLO-7113 (the dedupe/reopen path this text lives in).
No
Blockcast/paperclipissue exists for this; describing it here per path (B).Bug. Two defects on
server/src/services/recovery/service.ts, both observed in production on agentSummarizer(claude_local):(1) Adapter-boundary generalization. The re-fire reopen comment read, verbatim:
Every clause is inapplicable to a sessioned-local adapter:
EXTERNAL_LIFECYCLE_ADAPTER_TYPES = ["claude_k8s", "opencode_k8s"](packages/shared/src/validators/agent.ts:27).claude_localis not in it.provider: local,transport: local,externalRunId: null.resolveExternalLifecycleConcurrencyand the orphaned-Job early return at the dispatch gate are both guarded byexternalLifecycle; andrunningCountfilters to rows whose signal is withinRUN_STALE_SILENCE_MS(15 min), so a multi-hour-silent row contributes 0 to concurrency regardless.This is actively misleading remediation rather than noise: it invites a reader to "free" a lock by cancelling, or to escalate a scheduling fault, on a false premise. The same false premise appeared a second time in the suppression note (
the orphaned running row is the canonical BLO-4467-family wedge (pod already reaped)), so the two could drift apart independently.(2) The card's evidence block is blind for its entire population.
readRunLogTailForEvidenceopened with:logStore/logRefare written immediately afterrunLogStore.begin()—productivity-review.ts:1466-1467relies on exactly that property.logBytesis only written back on finalize. This detector fires only on rows that are stillrunning, i.e. never finalized, sologBytesis null-or-0 for 100% of the population and the read returned""every time. Production symptom: a card reporting "No run-log tail was available" andLast output sequence: 1while a readable 682-byte, 3-line log sat at the path the row's ownlogRefnames — with the single most diagnostic line,stderr: Adapter execution timeout: timeoutSec=900, invisible on both the row and the card.The gate was also unnecessary:
runLogStore.read→readLocalRangealreadyfs.stats the file and clampsstart/endto its real size, sologBytescan only ever be a seek hint.What Changed
staleRunOrphanedRowRemedy(adapterType), which returns both the remedy sentence and the short mechanism phrase. External-lifecycle adapters keep the existing BLO-4467 pod/Job/reaper text unchanged. Sessioned-local adapters get text that states what is actually true: nothing to reap, no lock held, cancelling frees nothing, the row is simply orphanedrunning— and the only route to terminal isPOST /heartbeat-runs/:runId/cancel, which is board-gated, so escalate rather than re-close.escalateStaleRunRefire, and the suppression note insuppressOrEscalateStaleRunRefire. Previously two independent string literals asserting the same mechanism.readRunLogTailForEvidenceno longer requiresrun.logBytes. It treats it as a seek hint, and when the hint is absent or stale-low it walks forward in 256 KiB chunks using the store's ownnextOffset, keeping only the trailing 8 KiB window. Bounded at 4 MiB scanned per call so a pathologically large log cannot turn a detector sweep into a multi-MB read; a partial tail is returned on read error instead of discarding it.Verification
Run in a durable clone (
/paperclip/work/pen-2106), basemaster@d92989b8b, head956ebfe81.masterhas advanced 3 commits since the base; none of them touch either file in this PR, so no rebase was needed.Typecheck —
pnpm --filter @paperclipai/server typecheck→ exit 0.This caught one real defect in the first commit, now fixed:
EXTERNAL_LIFECYCLE_ADAPTER_TYPESwas imported from@paperclipai/db, which does not export it. It lives inpackages/shared/src/validators/agent.ts, and the import now resolves it the same wayheartbeat.tsandservices/agents.tsdo.Suite —
vitest run server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts --no-file-parallelism --maxWorkers=1:27 pre-existing + 4 added, so nothing in the existing suite regressed.
Fail-first check
Each new test was run against the pre-fix source before the fix was credited:
git checkout d92989b8b -- server/src/services/recovery/service.ts, tests left in place, then-t "PEN-2106".runningbut the pod/Job is gone. Force-finish the run (reaper) so the agent's concurrency lock releases"logBytesis unwrittenAdapter execution timeout: timeoutSec=900; the whole tail was absentEach of the three reproduces the reported defect on the exact production string, not on a setup error.
The fourth passing pre-fix is intended and is not a gap. It is a positive control, not a regression test: it asserts that behaviour on
claude_k8sis unchanged, so it must pass on both sides. Its job is to fail if the adapter branch ever blanks both arms — i.e. if someone "fixes" the sessioned-local text by deleting the pod/Job remedy outright, the three above would still pass and only this one would catch it. A branch guarded in one direction only is how a text fix silently becomes a text deletion.Not run locally: the full
test:runmatrix and e2e. Those are running in CI on this head; theGeneral tests (server 1-4)shards are the relevant ones for this file.Risks
Low, with two things worth a reviewer's attention.
claude_localhas an observed 43.2h recovery tail — so that is a desirability decision with a real cost, not a defect fix, and it is not bundled here.collectStaleRunEvidenceis called once per non-skipped detector evaluation (sweeps run ~10–15 min apart), so a live wedge with a large log re-reads on each sweep. The observed population is 682-byte logs (one 8 KiB chunk), and the walk short-circuits immediately wheneverlogBytesis populated and sane, but the cap is the thing holding the worst case — flag if 4 MiB is judged too high.Two things I deliberately did not change, both arguably in the same family:
issues.ts:5347(readRunLogText) carries the samelogBytes <= 0gate. I left it alone: it derives issue-comment metadata for terminal runs, wherelogBytesis written, so it is not blind on its population the way this one was. Worth a separate look, not a drive-by here.recovery/service.ts:4783/:5882— thestranded_assigned_issueremedy ("fix the runtime/adapter failure") reproduced against PEN-2106 itself twice, labelling a provider 429 (BYOS provider capacity … retry in 16803s) asjob_failed — External lifecycle Job failed: BackoffLimitExceeded, and a CephFS mount fault ask8s_pod_schedule_failed. That is a real defect but a different one — an outer container verdict masking the body's terminal result, which is fix(heartbeat): recover orphaned runs' own terminal result instead of failing them job_missing (PEN-2421) #1443's shape, not an adapter-lifecycle branch. Fixing it here would mean changing failure classification, not remedy wording.Defect (2) in the linked issue — that the prescribed action is board-gated while the wrapper is reopened onto an agent — is addressed at the text layer only: the sessioned-local branch now names the grant boundary and says escalate. Actually re-routing the reopen to a board approval instead of an agent assignee is a routing change with its own design questions, and I have left it out rather than smuggle it in.
Model Used
Claude Opus 4.5 (
claude-opus-4-5), 1M context, extended thinking, with tool use / code execution (Claude Code agent run).Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templateTest boxes are now done (see Verification). The remaining two unchecked boxes are outcomes I cannot assert myself: CI is still running on this head, and Greptile has not reviewed it yet.