5959 * a future change that alters the trade — in either direction — has to come
6060 * through here and say so.
6161 *
62- * ## One measured case that does NOT stay inside those bounds
62+ * ## The one case that escaped those bounds — FIXED in #16151
6363 *
64- * `FINDING` below. Bound 1 holds only because the save eventually LANDS. Let the
65- * save FAIL after an evicting read has already run, and the compound outcome is
66- * a run with no durable row and no map entry: unresumable, and the engine's own
67- * `error` record for the failed save promises the opposite ("it is kept in
68- * memory only"). It is narrower than the base window — it needs a store that
69- * rejects the write while still answering reads with "no row" rather than
70- * throwing (a healthy read replica behind a broken write path, a missing INSERT
71- * grant, a full disk) — but it is not hypothetical, and it escapes the bound.
64+ * Bound 1 holds only because the save eventually LANDS. Let the save FAIL after
65+ * an evicting read has already run, and the compound outcome was a run with no
66+ * durable row and no map entry: unresumable in its own process, while the
67+ * engine's `error` record for that failed save promised the operator the
68+ * opposite ("it is kept in memory only ... after a restart"), which is the
69+ * direction that costs the most — an operator reading it looks for the run only
70+ * after the next restart, and blames the restart. It is narrower than the base
71+ * window — it needs a store that rejects the write while still answering reads
72+ * with "no row" rather than throwing (a healthy read replica behind a broken
73+ * write path, a missing INSERT grant, a full disk) — but it is not
74+ * hypothetical.
7275 *
73- * ⛔ It is deliberately NOT fixed here. Widening the cache-only marking is
74- * exactly the move this card forbids taking unilaterally, and the choice
75- * between that, a lock, and reordering the save is a decision above it. The
76- * case is pinned at its MEASURED behaviour so the cost is visible and so any
77- * future fix has a red test to turn green.
76+ * ⭐ #16150 pinned it at its MEASURED behaviour, explicitly not as desired, so a
77+ * fix would have a red test to turn green. #16151 turned it green: the catch in
78+ * `persistSuspendedRun` now RE-SEATS the map entry alongside the cache-only
79+ * marking, so the marking qualifies something again. The two tests below are
80+ * that pin, rewritten to the INTENDED behaviour — the run stays resumable
81+ * in-process, and the `error` record's promise is asserted against the state it
82+ * describes rather than merely read.
83+ *
84+ * ⛔ The fix is option C of that card, and nothing wider: the cache-only marking
85+ * is NOT widened (it still happens only after the save settles, and only on
86+ * failure — widening it would weaken #13617's store authority and is reserved
87+ * to its own review), no lock is added, and the save is not reordered. The base
88+ * window above is untouched and its two tests are unchanged.
7889 */
7990
8091import { describe , it , expect } from 'vitest' ;
@@ -88,6 +99,17 @@ function silentLogger(): any {
8899 return { info ( ) { } , warn ( ) { } , error ( ) { } , debug ( ) { } , child ( ) { return silentLogger ( ) ; } } ;
89100}
90101
102+ /** A logger that keeps every `error` message, so a promise the engine MAKES can
103+ * be asserted against the state it describes. */
104+ function recordingLogger ( errors : string [ ] ) : any {
105+ const log : any = {
106+ info ( ) { } , warn ( ) { } , debug ( ) { } ,
107+ error ( message : string ) { errors . push ( message ) ; } ,
108+ child ( ) { return log ; } ,
109+ } ;
110+ return log ;
111+ }
112+
91113/** start -> lv1 -> lv2 -> end. Two levels, so a re-suspend has somewhere to go. */
92114const APPROVAL_FLOW = {
93115 name : 'expense_approval' ,
@@ -106,8 +128,8 @@ const APPROVAL_FLOW = {
106128 ] ,
107129} as any ;
108130
109- function engineOver ( store : SuspendedRunStore | undefined ) : AutomationEngine {
110- const engine = new AutomationEngine ( silentLogger ( ) , store ) ;
131+ function engineOver ( store : SuspendedRunStore | undefined , logger : any = silentLogger ( ) ) : AutomationEngine {
132+ const engine = new AutomationEngine ( logger , store ) ;
111133 engine . registerNodeExecutor ( {
112134 type : 'approval_level' ,
113135 descriptor : defineActionDescriptor ( {
@@ -252,43 +274,56 @@ describe('#16129 — the mid-park window between the map write and the durable s
252274 } ) ;
253275} ) ;
254276
255- // -- the one case that escapes the bounds, pinned at its measured behaviour -- -
277+ // -- the one case that escaped the bounds, now pinned at the INTENDED outcome -
256278
257- describe ( '#16129 — the window compounded with a FAILING save' , ( ) => {
258- it ( 'FINDING: an evicting read inside the window of a save that then fails leaves the run unresumable' , async ( ) => {
259- // ⛔ Deliberately NOT fixed here — see this file's header. Pinned so the
260- // cost is visible and so a future fix has a red test to turn green.
279+ describe ( '#16151 — the window compounded with a FAILING save' , ( ) => {
280+ it ( 'an evicting read inside the window of a save that then FAILS leaves the run resumable in-process' , async ( ) => {
281+ // ⭐ This assertion set is #16150's `FINDING` pin, flipped. It recorded the
282+ // MEASURED loss (`hasSuspendedRun` false, `resume` → `RUN_NOT_FOUND`) and
283+ // said in its own header that it was pinned as measured and NOT as desired.
284+ // What follows is the desired behaviour, and it is what the engine's own
285+ // `error` record for a failed save has always promised.
261286 const inner = new InMemorySuspendedRunStore ( ) ;
262287 const { store, entered, release } = gatedSaveStore ( inner , { failSave : true } ) ;
263288 const engine = engineOver ( store ) ;
264289
265290 const parking = engine . execute ( 'expense_approval' ) ;
266291 const runId = ( await entered ) . runId ;
267292
268- // Same window, same evicting read.
293+ // The BASE window is unchanged by the fix — mid-park, the entry is still
294+ // unqualified, and the per-id read still evicts it. That half stays as
295+ // #16129 pinned it; only the compound outcome below moves.
269296 expect ( listedNodes ( engine . listSuspendedRuns ( ) , runId ) ) . toEqual ( [ 'lv1' ] ) ;
270297 expect ( await engine . hasSuspendedRun ( runId ) ) . toBe ( false ) ;
271298 expect ( listedNodes ( engine . listSuspendedRuns ( ) , runId ) ) . toEqual ( [ ] ) ;
272299
273- // The save now fails. `persistSuspendedRun` marks the run cache-only — but
274- // the map entry it qualifies is already gone , so the qualifier qualifies
275- // nothing and the strict loader has nothing left to serve .
300+ // The save now fails. `persistSuspendedRun` marks the run cache-only AND
301+ // re-seats the map entry that read evicted , so the marking qualifies
302+ // something again instead of qualifying nothing.
276303 release ( ) ;
277304 expect ( ( await parking ) . runId ) . toBe ( runId ) ;
278305
279- // ESCAPES BOUND 1. The store never took the row and the cache no longer
280- // holds it, so the run is unresumable — while the engine's `error` record
281- // for the failed save says it "is kept in memory only".
306+ // The fix invents no durability: the store still never took the row, and
307+ // the cross-restart loss the record reports is real.
282308 expect ( await inner . load ( runId ) ) . toBeNull ( ) ;
283- expect ( await engine . hasSuspendedRun ( runId ) ) . toBe ( false ) ;
284- expect ( listedNodes ( engine . listSuspendedRuns ( ) , runId ) ) . toEqual ( [ ] ) ;
285- const resumed = await approve ( engine , runId ) ;
286- expect ( resumed . success ) . toBe ( false ) ;
287- expect ( resumed . code ) . toBe ( 'RUN_NOT_FOUND' ) ;
288309
289- // The control that isolates the window as the cause: WITHOUT the mid-park
290- // read, the identical failing save is the documented degradation — the run
291- // stays resumable in-process, which is exactly what the promise says.
310+ // BOUND 1, RESTORED. The run is resumable in THIS process — the documented
311+ // degradation (a failed save costs cross-restart durability, not in-process
312+ // resumability) now holds in the compound case too.
313+ expect ( await engine . hasSuspendedRun ( runId ) ) . toBe ( true ) ;
314+ expect ( listedNodes ( engine . listSuspendedRuns ( ) , runId ) ) . toEqual ( [ 'lv1' ] ) ;
315+ expect ( await engine . getSuspendedScreen ( runId ) ) . not . toBeUndefined ( ) ;
316+
317+ // Resumable END TO END, not merely answering `true`: the run advances to
318+ // the next level (whose save fails the same way) and then completes.
319+ expect ( ( await approve ( engine , runId ) ) . status ) . toBe ( 'paused' ) ;
320+ expect ( listedNodes ( engine . listSuspendedRuns ( ) , runId ) ) . toEqual ( [ 'lv2' ] ) ;
321+ expect ( ( await approve ( engine , runId ) ) . success ) . toBe ( true ) ;
322+
323+ // The CONTROL that isolated the mid-park read as the cause, kept as-is:
324+ // WITHOUT the read, the identical failing save is the documented
325+ // degradation. It was the half that already behaved; the two now agree,
326+ // which is the whole content of the fix.
292327 const solo = engineOver ( {
293328 async save ( ) { throw new Error ( 'sqlite: attempt to write a readonly database' ) ; } ,
294329 async load ( ) { return null ; } ,
@@ -299,6 +334,39 @@ describe('#16129 — the window compounded with a FAILING save', () => {
299334 expect ( await solo . hasSuspendedRun ( soloRun ) ) . toBe ( true ) ;
300335 expect ( listedNodes ( solo . listSuspendedRuns ( ) , soloRun ) ) . toEqual ( [ 'lv1' ] ) ;
301336 } ) ;
337+
338+ it ( "the failed-save `error` record is TRUE in this interleaving: both reads it names answer for the run" , async ( ) => {
339+ // ⭐ The half of #16151 that is not about losing the run. The record told
340+ // the operator the run was "kept in memory only" and that they had until
341+ // the next restart to act; in this interleaving it was already gone, so the
342+ // message misdirected exactly the person who could still have acted. The
343+ // remedy is not to weaken the promise but to make it hold AND to make it
344+ // CHECKABLE: the message now names the two reads that must answer, so an
345+ // operator can falsify it instead of trusting it. This test asserts the
346+ // message against the state it describes — prose and behaviour cannot drift
347+ // apart without turning it red.
348+ const errors : string [ ] = [ ] ;
349+ const inner = new InMemorySuspendedRunStore ( ) ;
350+ const { store, entered, release } = gatedSaveStore ( inner , { failSave : true } ) ;
351+ const engine = engineOver ( store , recordingLogger ( errors ) ) ;
352+
353+ const parking = engine . execute ( 'expense_approval' ) ;
354+ const runId = ( await entered ) . runId ;
355+ expect ( await engine . hasSuspendedRun ( runId ) ) . toBe ( false ) ; // the evicting read
356+ release ( ) ;
357+ await parking ;
358+
359+ const record = errors . find ( m => m . includes ( 'failed to persist suspended run' ) ) ;
360+ expect ( record ) . toBeDefined ( ) ;
361+ // What it still says: in-memory survival now, no survival across a restart.
362+ expect ( record ) . toContain ( 'kept in memory only' ) ;
363+ expect ( record ) . toContain ( 'NOT be resumable after a restart' ) ;
364+ // What it now names — and each named read is asserted to actually answer.
365+ expect ( record ) . toContain ( 'hasSuspendedRun()' ) ;
366+ expect ( await engine . hasSuspendedRun ( runId ) ) . toBe ( true ) ;
367+ expect ( record ) . toContain ( 'listSuspendedRuns()' ) ;
368+ expect ( listedNodes ( engine . listSuspendedRuns ( ) , runId ) ) . toEqual ( [ 'lv1' ] ) ;
369+ } ) ;
302370} ) ;
303371
304372// -- controls: the shapes in which the window cannot bite ---------------------
0 commit comments