Uh oh!
There was an error while loading. Please reload this page.
fix(service-automation): a durable PAUSED run is visible to listRuns and run-detail after a cold restart (#8050) - #8150
Conversation
… restart (#8050) `sys_automation_run` holds two disjoint row families — terminal history rows (`run_`-prefixed, written on completion) and live suspension rows (keyed by the raw run id, status `paused`). `AutomationEngine.listRuns` merged the in-memory ring buffer with the first family only, and `getRun` fell back to the first family only, so after a process restart a parked run answered: GET /automation/:name/runs → 200, zero rows GET /automation/:name/runs?status=paused → 200, zero rows GET /automation/:name/runs/:runId → 404 RESOURCE_NOT_FOUND while the same run served `…/runs/:runId/screen` and resumed cleanly. Before a restart the gap is invisible because a paused run is still in the ring; after one, the ring is empty and the suspension rows had no reader. The sharp edge is `?status=paused` — #7359 had just made it a real filter, and with no post-restart producer of a `paused` entry it could never match a row, so the one query an operator reaches for was guaranteed to answer "nothing pending". Both reads now consult the suspension rows, through one rehydration (`suspendedRunToLogEntry`) that reproduces the entry the two `status: 'paused'` recordLog sites write — trigger attribution rebuilt via `buildRunTrigger` on the persisted context, and the #7639 variable snapshot carried through. Read-path only: no column, prefix or lifecycle changes, and paused rows are not reshaped into history rows. Merge precedence is stated and pinned — durable paused → durable history → in-memory ring, weakest first — because a paused row is the only source that can be stale (its delete on completion is best-effort), so a finished run is never reported as still waiting (#3456). The new read is best-effort like the history read beside it: a store outage degrades the listing and says so, rather than throwing. Tests: `paused-run-visibility.test.ts` — 15 cases, measured 8 red / 7 green against `origin/main` with only the engine change reverted, including a full-stack cold boot of a second kernel over the same sqlite FILE. Three cases drafted as "green" measured red and are relabelled with the reading rather than softened. `suspended-run-store.test.ts` has one contrast assertion inverted (`getRun` → null for a cross-restart pause was the defect, not the contract) and gains a case for the distinction that survives: `hasSuspendedRun` throws on an unreadable store where `getRun` degrades. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018P4qoXGyfvwYDMS57NftKL
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Closes#8050.
What was wrong
sys_automation_runholds two disjoint row families:run_+ runIdcompleted/failedrecordTerminalpausedsaveAutomationEngine.listRunsmerged the in-memory ring buffer with the firstfamily only, and
getRunfell back to the first family only. So after aprocess restart, a run parked at an
approval/screen/waitnode answered:GET /automation/:name/runsGET /automation/:name/runs?status=pausedGET /automation/:name/runs/:runIdRESOURCE_NOT_FOUNDstatus: 'paused'…while the same run served
…/runs/:runId/screenand resumed cleanly. Before arestart the gap is invisible, because a paused run is still in the ring — which
is why every existing in-process
listRunstest passes onmain.The sharp edge is the filter. #7359 had just made
?status=pauseda real filter,and with no post-restart producer of a
pausedentry it could never match arow — so the one query an operator reaches for when asking "what is in flight?"
was structurally guaranteed to answer "nothing pending".
What changed
Both reads consult the suspension rows, through one rehydration
(
suspendedRunToLogEntry) that reproduces the entry the twostatus: 'paused'recordLogsites write:triggeris rebuilt via thebuildRunTriggerchokepoint on the persistedcontext_json, not from the flattenedtrigger_*columns — those existfor filtering (
trigger.recordIdis never populated on record_change runs, and the persistedsys_automation_runrow carries no trigger block at all — runs cannot be correlated to their triggering record, and trigger kinds are lost across restart #7533) and spell an absent typenullwhere the log entry says'manual'.variablesis carried, because finding: a suspended flow run's variables are unreadable on run-detail — the pausedrecordLogomits them while the continuation keeps them #7639 made it part of what a paused rundiscloses on run-detail. Dropping it would have re-opened that card for exactly
the runs that need it most.
durationMs/completedAtare absent: a suspension row records when the runstarted, not when it parked.
getRungains the paused fallback after the terminal probe, andlistRunsmerges weakest-source-first — durable paused → durable history → in-memory
ring. That order is a claim, not an accident: a paused row is the only source
that can be stale (its delete on completion is best-effort), so a terminal row or
ring entry for the same id is later evidence and wins. Letting paused win would
re-introduce #3456's "paused forever". There is no symmetric hazard — within a
process the ring is written in the same breath as the paused row, and across a
restart the ring is empty.
What did not change
retention exemption, and are not reshaped into history rows.
survives a cold boot over the same sqlite file, still resumes to completion,
and both surfaces then report it terminal.
store.loadanswersnullfor an unknown id exactlyas
loadTerminaldoes, so a genuinely nonexistent run id still reachesdeps.error('Execution not found', 404).Reverse verification
Every case was run against
origin/mainwith only the engine change reverted(test file unchanged): 8 red / 7 green.
main?status=pausedreturns the parked run[][]nullnull[]?status=narrows without widening[](len 0, want 1)[]/nullThree cases drafted as green measured red, and the labels — not the tests —
were wrong: each asserts the row is there before asserting anything about its
scoping, so none can pass on a tree where it never appears. They are recorded as
red with the extra invariant each carries named on the case, and a genuinely
green pre-restart scoping twin was added alongside.
The trap this avoids: parking a run and enumerating it in the same process is
green on
maintoo. The gate is park → cold restart → read.Test edited (called out deliberately)
suspended-run-store.test.ts— one assertion inverted, none deleted.It sat inside a
hasSuspendedRuncase as a contrast ("the casegetRuncannot answer"). That contrast is the defect — it is what made run-detail 404
for a healthy parked run. Inverted rather than removed, so the pair stays pinned
together, plus a new case for the distinction that survives:
hasSuspendedRunrejects on an unreadable store (it backs a write decision) where
getRundegradesto
null(it is an observability read).Two now-false doc comments in
plugin-approvalsthat asserted the old contrastwere corrected. No behaviour changes there:
inspectStrandedRequestsgates onhasSuspendedRun === falsefirst, andreleaseDeadRunRequeststreatsnullandpausedthrough the same!TERMINAL_RUN_STATUSES.has(status)branch.Gates
service-automationsuiteplugin-approvalssuiteruntimedomain suitecheck-test-source-aliascheck-type-check-coveragecheck-type-check-coverage --re-measuretsc --noEmit(service-automation)nested-region-parity.test.ts; the new file adds 0 (ceiling 5)eslinton changed filesThe re-measure reports a pre-existing 270-error surplus across 9 entries
(
metadata,service-storage,plugin-auth,mcp,lint, … — mostly packagesuntouched here). Not lowered: out of scope, and the gate states lowering is
optional.
content/docs/releases/**,docs/adr/**and the skills trees are untouched.Changeset:
.changeset/paused-run-visibility-after-restart.md.Generated by Claude Code