fix(recovery): escalate stranded-lane wakes off status-only after a refused document write (BLO-32566) - #1707
Conversation
…efused document write (BLO-32566) A status-only recovery wake cannot write an issue document. Once a run on an issue has been refused exactly that write, re-dispatching status-only guarantees the identical 403 -- and because only a recorded disposition clears the recovery action, while that action is active *every* wake on the issue is status-only. The issue can then never produce the deliverable that would clear it. Reproduced four times: BLO-31222 x3, then on BLO-32566 itself, where a completed and verified instrument revision could not be landed. BLO-23197 (PR #1651, dd8c405/6f1d8e042) applied this escalation to the successful-run-handoff lane and scoped this lane out explicitly as follow-up: "the `stranded_assigned_issue` / `source_scoped_recovery_action` and productivity-review lanes ... are NOT addressed here; the new column is readable by them as follow-up." This is that follow-up. It is not a regression or an oversight in #1651. Three wake sites, not two. The audit that produced the issue named the two paths inside `enqueueSourceScopedStrandedRecoveryWake`. A third exists: `reconcileStrandedRecoveryWakeBackstopImpl`. They cover disjoint issue statuses -- the first fires from `reconcileStrandedAssignedIssues` over todo/in_progress/in_review, re-escalating with an incremented `attemptCount` each sweep; the backstop is the only re-wake path for an action whose issue is `blocked` (STRANDED_RECOVERY_WAKE_BACKSTOP_ISSUE_STATUSES). Gating one and not the other would leave the trap intact for half the status space. `planning_only` is the minimum escalation that clears it: normal model with `allowDocumentUpdates: true`, while deliverable and annotation writes stay barred -- the same choice #1651 made. Self-limiting rather than a ratchet: the stamp lives on the newest run row, and the escalated wake creates a new row that cannot carry it (the stamp is only written on the `statusOnly` branch), so one refusal buys one escalated wake, still bounded by `maxAttempts` and `timeoutAt`. Closes the projection hazard that would have made this silently no-op: `LatestIssueRun` is a hand-enumerated Pick<> and two of its four producers had hand-copied the column list rather than sharing `LATEST_ISSUE_RUN_COLUMNS`, despite that const's comment claiming the shapes "cannot drift apart". All producers now select the shared const, and the column is on the type -- so the explicit `Promise<LatestIssueRun>` return annotations make a short projection a compile error. Verified by deleting the column from the const: all four producers fail to typecheck. Also records the escalation on the company activity stream (`recoveryWorkClass`, `escalatedAfterDocumentWriteRefusal`, `documentWriteRefusedRunId`). The two prior occurrences were reported only inside the blocked issue's own documents and were lost; the refusal fact itself is already queryable as `heartbeat_runs.status_only_document_write_refused_at`. Not addressed here, deliberately: the monitor-wake coalescing leak (`mergeCoalescedContextSnapshot` does not drop the five recovery guard keys, and `withRecoveryModelProfileHint(x, "normal_model")` is scrub-only so it cannot displace them). It is read from source rather than reproduced, lives in heartbeat.ts, and the obvious patch is a guard *weakening* -- an incoming wake silent about run class would strip the guard off a genuinely status-only run. Filed separately. Tests: 5 new cases in issue-recovery-actions.test.ts, against embedded Postgres rather than row literals -- a literal-built unit test passes even when the projection is short, which is the failure mode being guarded. Each escalation case is paired with a sign guard asserting status-only is retained without the stamp, so a fix that escalated unconditionally fails. Verified the pair actually discriminates by forcing the helper to always return status_only: exactly the two escalation tests fail, the three others pass. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please review at head aa15ff0 — BLO-32566, recovery-lane scheduling change. Review focus, in priority order:
Not addressed here on purpose: the monitor-wake coalescing leak, filed separately — the obvious patch there is a guard weakening. |
|
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 |
Path B of `enqueueSourceScopedStrandedRecoveryWake` -- the `assignee_fallback` branch -- is the one BLO-31836's reopen bar named: it exists specifically to wake the *source assignee* when the upward owner has gone quiet, and it dispatched them status-only unconditionally. The existing escalation-time case drives whichever path the sweep selects, which with a fresh action is Path A. So the branch most specific to this defect was covered only incidentally. This asserts it directly: `attemptCount > 1`, owner != assignee, and issue activity older than the action's last attempt, then checks both things that distinguish this branch -- the wake targets the **assignee** rather than the owner, and its idempotency key ends `:assignee_fallback` -- as well as the escalation itself. Both timestamps are pinned rather than relative so the branch condition does not depend on wall clock. Verified it discriminates: with the helper forced to always return `status_only`, this test fails. Co-Authored-By: Claude <noreply@anthropic.com>
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: a759f82
The escalation itself is correct and the projection hardening is the right call — LATEST_ISSUE_RUN_COLUMNS is now genuinely shared by all producers, and the stamp write in routes/issues.ts:6962 is gated on statusOnly && mutationKind === "document", so the self-limiting argument in the PR body holds. Three Important findings below, all in the same family: the escalation gate is wired to a classification-scoped run rather than to the issue's newest run, and the durable signal lands on only one of the two lanes.
(Reviewed against a759f828. aa15ff07 was the head when this run started; the delta is test-only and adds the Path B coverage that was otherwise going to appear below as a suggestion. service.ts and model-profile-hint.ts are byte-identical across the two.)
Critical Issues (0)
None.
Important Issues (3)
-
[gstack/review + native-codex]
server/src/services/recovery/service.ts:5720— the escalation gate does not read "the newest run on this issue" that its own comment (5689-5719) claims, so on several lanes it is structurally always false.documentWriteWasRefusedreadsinput.latestRun, a parameter threaded from the sweep — not a fresh newest-run read. That variable is narrowed or nulled for reasons unrelated to the refusal stamp:service.ts:7911andservice.ts:7946setlatestRun = nullon the terminal-dispatch-race and adoption-handover paths, which silently disables the gate.service.ts:8078substitutesgetLatestIssueRunForAgentStage(...), filtered to the participant agent + stage.service.ts:8188substitutesgetLatestIssueRunSince(...), filtered oncontextSnapshot ->> 'interactionId'(service.ts:2910).
- The last two are the sharp ones. A
source_scoped_recovery_actionwake'scontextSnapshot— built right here atservice.ts:5779-5787— carriesissueId,taskId,wakeReason,recoveryActionId,sourceIssueId,strandedRunId,recoveryCause. NointeractionId, and no stage. So the run that gets stamped can never match either scoped producer, and on those twoin_reviewsub-lanesdocumentWriteWasRefusedis false by construction — the trap the PR closes elsewhere stays open there. That is the same "gating one site and not the other leaves the trap intact for half the status space" argument the PR body makes about the backstop, applied one level down. - Not a regression (those lanes behave exactly as before), but it undercuts the
in_reviewcoverage claimed in the PR body's three-wake-sites table. - Recommendation: do at the gate what the backstop already does correctly at
service.ts:12345— read the newest run for the issue directly rather than trusting the threaded parameter, e.g.const documentWriteWasRefused = Boolean((await getLatestIssueRun(input.issue.companyId, input.issue.id))?.statusOnlyDocumentWriteRefusedAt). That makes the comment's invariant true by construction and immunises the gate against future narrowing oflatestRun. If the threaded value is preferred for cost reasons, narrow the comment to say the gate reads the sweep's classification run, and name the lanes where it cannot fire.
-
[pr-review-toolkit: tests]
server/src/__tests__/issue-recovery-actions.test.ts:8008— the projection guard is a Postgres round-trip tautology; it does not exercise the projection it claims to be the only thing able to catch.- The body seeds a row via
seedNewestIssueRunand then issues its own ad-hocdb.select({ stamp: heartbeatRuns.statusOnlyDocumentWriteRefusedAt }). It never callsgetLatestIssueRun, never touchesLATEST_ISSUE_RUN_COLUMNS, and never constructsrecoveryService. It would pass unchanged if all four producers dropped the column — precisely the failure mode its doc comment and the mirrored comment atservice.ts:746-750say it guards. - The genuine coverage does exist: the escalation cases read through the real projection end-to-end (
getLatestIssueRunatservice.ts:12345andservice.ts:7897), and the explicitPromise<LatestIssueRun>annotations make a short projection a compile error. So this is redundant as well as tautological. - Recommendation: delete it and let the block comment rest on the cases that do exercise the projection, or convert it to assert the consequence through the service (e.g.
documentWriteRefusedRunId === refusedRunIdfrom the backstop's activity entry).getLatestIssueRunis a closure insiderecoveryServiceand is not exported, so a reconciler is the only honest runtime entry point.
- The body seeds a row via
-
[native-codex]
server/src/services/recovery/service.ts:12410-12412— the durable activity signal is recorded on the backstop lane only, which is the inverse of where the reported occurrences happened.recoveryWorkClass,escalatedAfterDocumentWriteRefusalanddocumentWriteRefusedRunIdappear at exactly one place in the file. The backstop coversblockedissues; the escalation-time site covers todo / in_progress / in_review — the statuses of the BLO-31222 ×3 occurrences the PR cites. So "which issues escalated off status-only, and when" is unanswerable from the activity stream for the majority lane, and the PR body's "Durable signal outside the blocked issue" AC is met only for blocked issues.- Recommendation: carry the same three fields on the escalation-time activity row (the details block around
service.ts:7280-7310). It needsdocumentWriteWasRefusedthreaded out ofenqueueSourceScopedStrandedRecoveryWake, or computed once before the transaction — which the first finding's fresh read would make natural.
Suggestions (4)
- [pr-review-toolkit: comments]
server/src/services/recovery/service.ts:750andservice.ts:2121— the producer count is wrong in a PR whose thesis is that an un-audited producer silently disables the feature. Both say "three"; there are four (getLatestIssueRun2143,getCheckoutAdoptingRun2168,getLatestIssueRunForAgentStage2373,getLatestIssueRunSince2902), and the test comment attest.ts:8003correctly says four. All four do share the const at head, so this is comment-only — but an under-count is what invites the next drift. - [pr-review-toolkit: tests]
server/src/__tests__/issue-recovery-actions.test.ts:7941— the new Path B case is a good addition and the assignee-vs-owner assertion is the right one, but it arrives without the paired sign guard the rest of the block uses. Every other escalation case here has a "stays status-only when no document write was refused" twin, and that pairing is what proves the stamp is the variable. Path B currently has the positive half only, so a Path B gate wired to escalate unconditionally would still pass. One more case with the same seed minusstatusOnlyDocumentWriteRefusedAtwould close it. - [native-codex]
server/src/services/recovery/model-profile-hint.ts:127— worth linking the coalescing follow-up from here rather than only from the PR description.withRecoveryModelProfileHintsets the four guard keys explicitly on theplanning_onlyarm, so those survive a merge intact, butmodelProfileis scrub-only. An escalated wake coalesced with a queued status-only wake can therefore retainmodelProfile: "cheap"while carryingallowDocumentUpdates: true. The document write still succeeds, so the fix is not defeated — but the escalated run may execute on the cheap profile, which is the one residual worth a comment pointer to the filed issue. - [pr-review-toolkit: types]
server/src/services/recovery/model-profile-hint.ts:129—withStrandedRecoveryWakeWorkClasshas no explicit return annotation, unlike every neighbouring export. The inferred union is actually good (modelProfileexists on only one arm, so reading it is a compile error rather than silently optional); annotating it just makes that intentional rather than incidental.
Strengths
- The projection hazard is the best part of the change: noticing that two producers had hand-copied a list whose own comment claimed it could not drift, then verifying the claim by deleting the column and citing the four resulting TS2322 errors, is the right standard of proof.
- Finding the third wake site (
reconcileStrandedRecoveryWakeBackstopImpl) that the originating audit missed, and documenting the disjoint-status reasoning inline atservice.ts:12316-12330rather than only in the PR body. - The sign-guard pairing genuinely discriminates: both negative cases assert
recoveryIntent,allowDocumentUpdatesandmodelProfile: "cheap", and the positive cases assert the absence ofmodelProfileexplicitly rather than relying ontoMatchObjectsubset semantics — which is the assertion that would otherwise have missed a leftovercheap. - Placing the backstop's
getLatestIssueRuninside the existingtryis correct and the reasoning atservice.ts:12336-12343is right: it is the first I/O after the claim, and outside the try it would abort the sweep for every remaining candidate with the attempt already spent. - The coalescing leak was read from source, judged, and deliberately deferred with the reason stated (the obvious patch weakens a guard) rather than bundled in.
- The Path B test added in
a759f828pins both clock-dependent inputs rather than relying on wall clock, and asserts the woken agent as well as the work class — the assignee-vs-owner distinction is the part that would otherwise silently regress.
Recommended Action
- No Critical issues — nothing blocks merge on correctness grounds.
- Address the three Important issues this cycle. The first is the substantive one: the gate reads a scoped run, so the fix does not reach the two
in_reviewsub-lanes it is documented to cover, and mirroring the backstop's fresh read fixes that and the third finding together. - Suggestions opportunistically; the producer-count comment is a two-word edit and worth taking with the rest.
…tion
A step killed by its `timeout-minutes` emits only
##[error]The action '<step>' has timed out after 1 minutes.
with no test output and no elapsed time, so in the checks UI it is
indistinguishable from "your diff broke this test" — which is what sent
the author of #1707 hunting in the wrong file. BLO-32670 re-budgeted the
three steps most likely to trip it; it did not make the remaining case
legible, and any step can still be killed under load.
Add scripts/classify-policy-step-kills.mjs, run last in `policy` and only
on `failure()`, which re-emits the kill as a `::error::` annotation naming
the step, its budget and how long it actually ran.
Detection reads GitHub's own verdict rather than comparing elapsed time
against the budget. The banked kill (run 34154564717 attempt 1, commit
a759f82) ran 73s under a 60s bound, so the "elapsed is within 2s of the
budget" rule this was originally scoped with would have scored its own
negative control as a pass-through: GitHub sends the kill at the bound and
then bills the teardown, and that overshoot is unbounded under load.
The pattern is anchored to the runner's exact phrasing rather than matching
/timed out/. A real assertion failure in the same run carried "row-lock
replay timed out after 1000ms" from a test's own internal timeout, and a
substring match would have relabelled that genuine defect as an
infrastructure flake — the one outcome that makes this worse than nothing,
because a misattributed red gets re-run instead of fixed.
The classifier cannot change the gate: `continue-on-error: true`, exits 0
on every path including its own errors, and emits annotations only. It
also cannot degrade silently — an unreadable annotation set is reported as
a `::warning::`, since silence is indistinguishable from "nothing was
killed", which is the bug. A killed step's annotations may still be
propagating when this runs, so it polls briefly on "job has a failed step
but reports no failure annotation" before giving up and saying so.
`policy` declares no `permissions:` today, and a job-level block replaces
the workflow-level one rather than merging, so `contents: read` is listed
explicitly alongside `actions: read` and `checks: read` — without it
`actions/checkout` and every later step in the job would break. The test
pins all three.
Verified end-to-end against the live Actions API: the banked kill yields
the annotation with its real 73s duration, and the same job's green re-run
yields nothing — despite that green run carrying a failure-level
`Process completed with exit code 1.` annotation of its own, which is why
"has a failure annotation" is not a usable proxy for "a step was killed".
Refs BLO-32682. Split out of BLO-32670 (#1713).
Thinking Path
Linked Issues or Issue Description
Duplicate/related PR search (
recovery,status_only,stranded,planning_only): the only prior art is #1651 (merged), whose follow-up this is. No open PR addresses this defect.Two open PRs touch the same files and may conflict textually — flagging rather than assuming:
observability(recovery): emit backstop sweep-completion signal— touches the same two files and the samereconcileStrandedRecoveryWakeBackstopImpl. Highest conflict risk; likely mechanical (its edits are to the result/logging block, mine to the wake payload).fix(recovery): escalate an exhausted successful-run handoff left in todo— alsoservice.ts, but the successful-run-handoff lane rather than the stranded lane.What Changed
status_onlywhen the newest run on the issue carriesheartbeat_runs.status_only_document_write_refused_at:enqueueSourceScopedStrandedRecoveryWakePath A (owner wake) and Path B (assignee_fallback) — covertodo/in_progress/in_reviewreconcileStrandedRecoveryWakeBackstopImpl— the only re-wake path once the issue isblocked. This site was not in the originating audit, which named only the two above. The status coverage is disjoint, so gating one set would leave the trap intact for the other.withStrandedRecoveryWakeWorkClass(input, escalate)inmodel-profile-hint.ts, rather than a fourth union-accepting overload onwithRecoveryModelProfileHint— keeps each arm resolving against its precise overload and avoids widening the public API of a cost guard.statusOnlyDocumentWriteRefusedAttoLatestIssueRunand toLATEST_ISSUE_RUN_COLUMNS.getLatestIssueRunForAgentStage,getLatestIssueRunSince) onto the shared const. They had duplicated the column list despite that const's comment claiming the shapes "cannot drift apart".recoveryWorkClass,escalatedAfterDocumentWriteRefusal,documentWriteRefusedRunId), so a refusal is discoverable outside the blocked issue's own documents.Verification
Two verifications worth reproducing, because each guards a failure mode that would otherwise be invisible:
1. The tests actually discriminate. Forcing
withStrandedRecoveryWakeWorkClassto always returnstatus_only(simulating pre-fix behaviour) fails exactly the two escalation tests, while the two sign guards and the projection test still pass:2. The projection guard is real. Deleting
statusOnlyDocumentWriteRefusedAtfromLATEST_ISSUE_RUN_COLUMNSnow fails all four producers at compile time:The tests run against embedded Postgres rather than row literals on purpose: a literal-built unit test passes even when the projection is short, which is precisely the hazard #1651's author flagged in a code comment on their own fix.
Risks
Cost — bounded and self-limiting.
planning_onlydropsmodelProfile: cheap, so an escalated wake costs a normal-model run. It fires only on a stamped refusal (a recorded fact, not a heuristic). The stamp lives on the newest run row and is only written on thestatusOnlybranch, so the escalated wake creates a row that cannot carry it — one refusal buys one escalated wake. If an escalated run dies without writing, the next wake isstatus_onlyagain and may re-refuse: an alternation, not a loop, still bounded bymaxAttemptsand the creation-anchoredtimeoutAt.Sign of the gate. An inverted or unconditional gate would move every stranded recovery wake onto the normal model — the failure in the expensive direction. Each escalation test is paired with a sign guard for this reason, and the discrimination check above confirms the pairing bites.
New I/O in the backstop. One extra
getLatestIssueRunper candidate that has passed every gate and won the claim — i.e. that is about to have a wake enqueued anyway; the loop already does several per-candidate awaits. Placed inside the existingtry: everything between the claim and that try was previously pure string building, so this is the first thing there that can throw. Left outside, a transient failure would escape the per-candidate catch, abort the whole sweep, and leave the attempt claimed with no wake delivered.Deliberately not fixed here: the monitor-wake coalescing leak (BLO-32634).
mergeCoalescedContextSnapshotdoes not drop the five recovery guard keys, andwithRecoveryModelProfileHint(x, "normal_model")is scrub-only, so a monitor fire coalescing into astatus_onlyrow retains the guard tuple. The obvious patch — adding those keys to the drop list — is a guard weakening: a wake silent about run class would strip the guard off a genuinely status-only run, which is unbounded in the expensive direction. It is also source-read-only, not reproduced. It needs its own diff and its own reproduction.Not verified: whether the productivity-review lane has the same exposure. It hardcodes
status_only; #1651 named it as a third un-addressed lane and its owner resolver was not traced here.Migration safety: none — no schema change. The column already exists (added by #1651).
Model Used
Claude Opus 5 (
claude-opus-5), 1M context, extended thinking, tool use — running as Claude Code in the Paperclip agent harness.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templateLATEST_ISSUE_RUN_COLUMNSis corrected