fix(recovery): leave a receipt when a routine execution is cancelled (BLO-27572) - #1378
Conversation
…(BLO-27572) `postRoutineSchedulerFailureHeartbeat` had exactly one call site, inside `escalateStrandedAssignedIssue`, so the receipt only ever fired from the stranded-issue recovery sweep. An agent or human who retired a stranded window by cancelling its execution issue through the ordinary issue-update path never reached that code, and the window went dark with no row on the routine's alert surface. Disposition was a second way to manufacture silence -- the exact failure this alarm exists to remove. Extract the heartbeat to a dependency-light leaf module and call it from the cancellation transition too. The module takes `addComment` as an injected port rather than importing the issues service: the dependency direction is `recovery/service.ts -> issues.ts -> recovery/origins.ts`, so importing `../issues.js` from a module that `issues.ts` itself imports would close a cycle. Both call sites share `buildSchedulerFailureHeartbeatKey`, so a window that stranded and was then cancelled collapses onto one row rather than raising the same dark window twice. The receipt stays a timestamped observation rather than a terminal claim, so a later recovered emission reads as "stranded, then recovered" instead of a contradiction. The duplicate-suppressed cancellation stays silent via an explicit `suppressRoutineSchedulerFailureHeartbeat` flag set by `cancelDuplicateSuppressedRoutineExecutionIssue`. That window is not dark: another open execution issue already owns the dispatch lock and is doing the work, so a receipt there would be a false alarm. Explicit rather than inferred, matching the per-branch suppression the strand-time path already uses -- silence is the dangerous default here, so each instance of it should be written down. The cancel-time post runs after the transaction commits, on `db` rather than the caller's tx, mirroring the strand-time call site: it writes to a different issue, so an in-transaction failure would roll back a cancellation that is otherwise correct and already decided. Co-Authored-By: Claude <noreply@anthropic.com>
1 similar comment
|
@ally please review at head Review focus, in priority order:
Both negative-space tests were proven non-vacuous by deliberate sabotage runs (documented in the PR body) — please sanity-check that the two absence-asserting tests really would catch a regression, since those are the ones that fail silently. |
Thinking Path
Linked Issues or Issue Description
stop dispatching runs against cancelled issues) touches the dispatch side of cancelled routine executions; it does not overlap with the receipt path changed here.What Changed
server/src/services/recovery/routine-scheduler-heartbeat.ts—postRoutineSchedulerFailureHeartbeatmoved out of the recovery service closure. It takesdb,addCommentandloggeras injected dependencies and adispositiondiscriminated union (stranded|cancelled). TakingaddCommentas a port is what keeps it a leaf: the dependency direction isrecovery/service.ts → issues.ts → recovery/origins.ts, so importing../issues.jshere would close a cycle.services/issues.ts—updatenow records a routine-execution→ cancelledtransition under the row lock and posts the receipt after the transaction commits, ondbrather than the caller's tx. Mirrors the existing strand-time call site: the receipt targets a different issue, so an in-transaction failure would roll back a cancellation that is otherwise correct and already decided.services/issues.ts— newsuppressRoutineSchedulerFailureHeartbeatoption onupdate.services/recovery/service.ts— inline copy deleted; strand-time call site now uses the shared module.cancelDuplicateSuppressedRoutineExecutionIssuepasses the suppression flag explicitly, because that window is not dark — another open execution issue owns the dispatch lock and is doing the work, so a receipt would be a false alarm.buildSchedulerFailureHeartbeatKey({routineId, windowKey}), so strand-then-cancel collapses to one row via the existingonConflictDoNothingon comment idempotency keys.issue-recovery-actions.test.ts.Two small type fixes were needed and are called out in comments rather than left as mystery casts:
isRoutineExecutionDuplicateSuppressedRunis a type predicate, so its negative branch narrowsinput.latestRuntonull— the unnarrowed value is captured before the guard. And the deferred receipt is read through an accessor because control-flow analysis still has the variable narrowed tonullat the read site.Verification
The four new cases mirror the four acceptance criteria: cancel-with-no-receipt emits one; cancel-with-normal-receipt emits none; duplicate-suppressed cancel emits none; strand-then-cancel emits exactly one total. The three pre-existing BLO-21395 / BLO-24543 tests pass unchanged, which is the control proving the extraction did not alter strand-time behaviour.
Both negative-space claims were proven non-vacuous rather than assumed — two of the four assert an absence, which would pass against a no-op:
...cancelled through the ordinary issue-update pathexpected [] to have a length of 1 but got +0false...duplicate-suppressed routine execution is cancelledexpected [ Array(1) ] to have a length of +0 but got 1The second also confirms the duplicate-suppressed cancel genuinely flows through the new hook and would raise a false alarm unsuppressed — i.e. the flag is load-bearing, not decorative.
Wider runs:
routines-service/routines-routes/routines-e2e/routine-run-telemetry/issue-comment-cancel-routes/issue-list-routine-executions-routes/issue-update-comment-wakeup-routes— 111 passed (111)issue-*suites — 952/953. The one failure isissue-create-pr-review-duplicate-routes.test.tsdying on a Postgres40P01deadlock in the teardowntruncate ... cascadeagainst a concurrent test file; it passes 30/30 in isolation, is on the issue-create path, and is unrelated to this change.npx tsc --noEmit -p server/tsconfig.json— clean for all files touched here. Pre-existing errors remain inbetter-auth.ts,plugin-loader.ts,plugin-registry.ts,successful-run-handoff-state.ts, none of which this PR modifies.node scripts/check-test-undefined-symbols.mjs— exit 0.Risks
Low-to-moderate, concentrated in one place:
issueService'sreturn { ... }becameconst service = { ... }; return service;so the factory can bind theaddCommentport. That is mechanically equivalent and type-checked, but it touches the shared entry point for every issue mutation, which is why the wider suites above were run rather than just the targeted file.agent-health:<window>:*receipt now posts one row. Intended, and bounded to one row per (routine, window) by the shared idempotency key.windowCoverage7d.Model Used
claude-opus-4-5), 1M context, extended thinking, via Claude Code with tool use and code execution.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template