diff --git a/.changeset/strand-verdict-survives-bookkeeping-throw.md b/.changeset/strand-verdict-survives-bookkeeping-throw.md new file mode 100644 index 0000000000..06fd07ab70 --- /dev/null +++ b/.changeset/strand-verdict-survives-bookkeeping-throw.md @@ -0,0 +1,17 @@ +--- +'@objectstack/service-automation': patch +--- + +Keep a resume's `status: 'stranded'` verdict when the bookkeeping after the repair journal throws. + +`resumeInternal`'s catch arm journals the consumed suspension — the snapshot `restoreConsumedSuspension` puts back — and only then stamps `status: 'stranded'`. Two statements sat between them and could throw out of the whole arm: `recordLog`'s terminal run-summary line, and a store whose `recordTerminal` throws synchronously (the `void write.catch(...)` beneath that call only ever sees a returned promise's rejection). `failAncestors` follows them. + +A throw in that window left the run genuinely repairable while the verdict never shipped, and every consumer derives repairability from the verdict — `plugin-approvals` computes its operator-facing `repairable` as `status === 'stranded'` — so the approvals decision door reported `repairable: false` about a run that `restoreConsumedSuspension` answers `restored: true` for. That is a false negative on a repair instruction: it tells an operator not to attempt a repair that works. + +The window is now guarded. The bookkeeping may still fail — and says so loudly, at `error`, naming the run, what did not land, and the verb that repairs the strand — while the verdict still ships. Measured: with a store whose terminal write throws, `resume` now returns `{ success: false, status: 'stranded' }` instead of throwing, the door reports `repairable: true`, and the repair verb succeeds on that same run. + +The guard opens **after** the journal, so only a run that demonstrably has a snapshot can reach the stamp: a throw from the journal itself still propagates, every exit above the consumption point still carries no status at all, and cascade-failed ancestors — which journal nothing — are untouched and still correctly non-repairable. + +⚠️ This change also makes a pre-existing fault **visible** rather than creating it. The completion path's history write sits inside the same `try` as the node-failure arm, so a run that **completed** — every node succeeded — is journalled and reported `stranded` when its `completed` history row throws, and repairing such a run **re-runs the flow**. That phantom, its repair snapshot and the double run were all measurable before this change; what changes here is only that more store failures now report the verdict instead of throwing over it, so an operator can now be told to repair a completed run. Filed as #15944, with the measurement on both trees. + +⚠️ `repairable` remains a point-in-time fact, and this change does not make it durable: the run in the case above has no terminal history row (that write is what failed), so the repair rides on the in-memory journal and a restart loses it. The verdict reports what an operator can do now, which is exactly what was being denied. diff --git a/packages/plugins/plugin-approvals/src/decision-strand-envelope.test.ts b/packages/plugins/plugin-approvals/src/decision-strand-envelope.test.ts index db0cedd5be..6d22839bae 100644 --- a/packages/plugins/plugin-approvals/src/decision-strand-envelope.test.ts +++ b/packages/plugins/plugin-approvals/src/decision-strand-envelope.test.ts @@ -39,10 +39,35 @@ * 3. `'stranded'` observed AT THE DOOR, not dropped — with its reverse * control, a resume failure the engine does NOT call stranded, which must * report `repairable: false` rather than inheriting a default. + * + * ## The fourth pin (#15555) — the false NEGATIVE + * + * PIN 3's reverse control holds that the ABSENCE of the stamp must not be read + * as repairable. #15555 is the other half of the same reading: the stamp could + * go missing on a run that IS repairable, because the engine journalled the + * repair snapshot and then threw before stamping. The door then answered + * `repairable: false` — correctly from its own point of view, and wrong about + * the world — telling an operator not to attempt a repair that succeeds. + * PIN 4 drives that window through this same door and asserts the fix at the + * only place it matters: what the operator is told. */ import { describe, it, expect, beforeEach } from 'vitest'; import { AutomationEngine, InMemorySuspendedRunStore } from '@objectstack/service-automation'; + +/** + * [#15555] A durable run-history store whose terminal write throws + * SYNCHRONOUSLY — the statement that sits between the engine's repair journal + * and its `status: 'stranded'` stamp. The engine's own `void write.catch(...)` + * beneath that call only ever sees a returned promise's rejection, so a + * synchronous throw escapes the whole catch arm. + */ +const TERMINAL_WRITE_FAILURE = 'run-history driver refused the terminal row'; +class SyncThrowTerminalStore extends InMemorySuspendedRunStore { + override recordTerminal(): Promise { + throw new Error(TERMINAL_WRITE_FAILURE); + } +} // [#4550] The engine doubles below route their write verbs through ObjectQL's // OWN dispatch predicates rather than a hand-mirrored copy — a double looser // than the engine it stands in for is how #4434 shipped a dead REST route with @@ -141,8 +166,8 @@ describe('#13807 — a stranded decision publishes its facts, and keeps its stat let rejectBranchThrows: string | undefined; /** One live process: real engine, real approval node, real approvals service. */ - function boot() { - const automation = new AutomationEngine(noopLogger as any, new InMemorySuspendedRunStore()); + function boot(store?: InMemorySuspendedRunStore) { + const automation = new AutomationEngine(noopLogger as any, store ?? new InMemorySuspendedRunStore()); registerApprovalNode(automation, service, noopLogger as any); automation.registerNodeExecutor({ type: 'mark', @@ -308,4 +333,47 @@ describe('#13807 — a stranded decision publishes its facts, and keeps its stat expect(lostDetails?.runId, 'and it still names the run an operator must look at') .toBe(lostReq.flow_run_id); }); + + it('PIN 4 — #15555: a strand whose own bookkeeping throws is STILL reported repairable', async () => { + // The false NEGATIVE, driven end to end through the public door. The + // engine consumes the pause, journals the repair snapshot, and then the + // durable run-history write throws out of the arm before the + // `status: 'stranded'` stamp is reached. Before the engine-side guard the + // door received a raw throw carrying no run-state discriminator at all, + // so `repairable = status === 'stranded'` answered FALSE — about a run the + // repair verb puts back successfully, asserted below on the same run. + rejectBranchThrows = 'the node blew up'; + const automation = boot(new SyncThrowTerminalStore()); + const req = await park(automation); + const runId = req.flow_run_id; + + const err = await service + .decide(req.id, { decision: 'reject', actorId: 'u1' }, SYSTEM_CTX) + .then(() => null, (e: Error) => e); + + // The door still throws and the decision still stands — this card moves + // neither. ⛔ The status code and the `finalized` fact are the #13807 + // ruling's and are untouched. + const details = strandedDecisionDetails(err); + expect(details?.finalized).toBe(true); + expect(details?.decision).toBe('reject'); + expect(details?.runId).toBe(runId); + // ⭐ The card: the operator must be told the repair is worth attempting. + expect(details?.repairable, 'a journalled strand is repairable even when its bookkeeping failed').toBe(true); + + // The two assertions that make the old `false` a FALSE NEGATIVE rather + // than a conservative default: the run really is stranded, and the verb + // the operator was told not to bother with really does put it back. + expect(await automation.hasSuspendedRun(runId)).toBe(false); + const restored = await automation.restoreConsumedSuspension(runId, { requestedBy: 'ops' }); + expect(restored.restored, 'the repair the caller was told not to attempt').toBe(true); + expect(await automation.hasSuspendedRun(runId)).toBe(true); + + // And the prose names the run's OWN failure, not the history driver's: + // the secondary failure is an operator fact and belongs in the engine's + // log, never in the sentence that explains why the flow stopped. + expect(err?.message).toMatch(/^RESUME_FAILED/); + expect(err?.message).toContain(rejectBranchThrows); + expect(err?.message).not.toContain(TERMINAL_WRITE_FAILURE); + }); }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 3a44f8da78..e6fa6ff8f9 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -5515,23 +5515,90 @@ export class AutomationEngine implements IAutomationService { // ordering above is untouched. It is the evidence a repair // needs, written at the only moment it still exists. const consumed = this.journalConsumedSuspension(run, stepCountAtPause, errorMessage); - const logged = this.recordLog({ - id: runId, - flowName: run.flowName, - flowVersion: run.flowVersion, - status: 'failed', - startedAt: run.startedAt, - completedAt: new Date().toISOString(), - durationMs, - trigger: buildRunTrigger(context), - steps, - error: errorMessage, - }, context, consumed.run); - // Subflow chain: a child failing terminally fails every - // ancestor awaiting it — they can never be resumed otherwise. - // The delegation path handles its own level (skipBubble). - if (!skipBubble) { - await this.failAncestors(run.context, errorMessage); + // [#15555] From the line above, "this run is repairable" is a + // FACT: a snapshot exists and `restoreConsumedSuspension` puts + // it back. Everything from here to the `status: 'stranded'` + // stamp below is BOOKKEEPING ABOUT that fact — and none of it + // may be allowed to delete the fact by throwing. + // + // It could, and the direction is the dangerous one. The stamp + // is the ONLY thing that tells a consumer the run is + // repairable (`plugin-approvals` derives its operator-facing + // `repairable` from it verbatim: `status === 'stranded'`), and + // two statements in here throw out of the whole arm. Both live + // in `recordLog`'s terminal path: the run-summary line + // (`logger.info`, on by default) and `store.recordTerminal`, + // whose SYNCHRONOUS throw escapes — the `void write.catch(...)` + // beneath that call only ever sees a returned promise's + // rejection. Then `failAncestors` awaits a walk that can throw. + // A throw anywhere in that window replaced a truthful + // `repairable: true` with `false`, which does not merely lose + // information: it tells an operator NOT to attempt a repair + // that succeeds. A false negative on a repair instruction is + // worse than silence, and it is the opposite of the direction + // everybody checks for. + // + // ⛔ This is NOT "assume repairable when the failure is + // unknown" — that would invert the honest default and promise + // a repair for a lost run. The guard opens AFTER the journal, + // so only a run that demonstrably HAS a snapshot can reach the + // stamp: a throw from `journalConsumedSuspension` itself still + // propagates, and every exit above the consumption point is + // untouched and still carries no status at all. + // + // ⛔ And the journal is NOT moved down to sit beside the stamp + // instead. `recordLog` is what carries the snapshot into the + // durable row, so journalling after it would leave a + // `recordLog` failure with NO snapshot anywhere — converting + // this false negative into a TRUE one by destroying the repair + // rather than by reporting it. + let logged: ExecutionLogEntry | undefined; + try { + logged = this.recordLog({ + id: runId, + flowName: run.flowName, + flowVersion: run.flowVersion, + status: 'failed', + startedAt: run.startedAt, + completedAt: new Date().toISOString(), + durationMs, + trigger: buildRunTrigger(context), + steps, + error: errorMessage, + }, context, consumed.run); + // Subflow chain: a child failing terminally fails every + // ancestor awaiting it — they can never be resumed otherwise. + // The delegation path handles its own level (skipBubble). + if (!skipBubble) { + await this.failAncestors(run.context, errorMessage); + } + } catch (bookkeeping) { + // #4632 verdict: DURABILITY, so `error` — the caller is + // told a truthful, actionable thing (the run stranded, and + // it is repairable), which is exactly what makes the rest + // invisible from the outside: the terminal history row + // never landed and/or the ancestor cascade stopped + // part-way, nothing retries either, and no envelope + // carries a word about it. Consequence and fix in the + // first line, per AGENTS.md. Said ONCE per stranded run, + // not once per failed write. + // + // THIRD argument per `error(message, error?, meta?)`; the + // `Error` slot stays empty on purpose (#5575), and the + // thrown text goes to the structured slot rather than into + // the message (#6499). + this.logger.error( + `[Automation] run '${runId}' of flow '${run.flowName}' is STRANDED and its ` + + `post-strand bookkeeping threw, so its terminal history row never landed ` + + `and/or its subflow ancestors were not failed — nothing retries either, and ` + + `the run reads healthy to the Runs surfaces and the approvals sweeps. The ` + + `strand itself IS reported and repairable right now: restore it with ` + + `restoreConsumedSuspension('${runId}') before this process restarts, which ` + + `drops the in-memory journal this repair rides on. Fix the failure in this ` + + `record's meta.`, + undefined, + describeThrownForLog(bookkeeping), + ); } // Surface the flow's friendly error message (the raw error stays // in `error` for logs/diagnostics). @@ -5569,7 +5636,11 @@ export class AutomationEngine implements IAutomationService { // worse) condition, which this stamp must not claim. status: 'stranded', errorMessage: flow.errorMessage, - summary: logged.summary, + // [#15555] Recomputed when the guard above had to abandon + // `recordLog`: the same pure function of the same steps + // that `recordLog`'s own first statement runs, so the two + // spellings cannot disagree. + summary: logged?.summary ?? summarizeRun(steps), }; } } finally { diff --git a/packages/services/service-automation/src/strand-verdict-post-journal-throw.test.ts b/packages/services/service-automation/src/strand-verdict-post-journal-throw.test.ts new file mode 100644 index 0000000000..0079c4ee47 --- /dev/null +++ b/packages/services/service-automation/src/strand-verdict-post-journal-throw.test.ts @@ -0,0 +1,302 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #15555 — the strand VERDICT must not be lost when a statement after the + * journal throws. + * + * ## The reported defect, in one sentence + * + * `journalConsumedSuspension` and the `status: 'stranded'` stamp are two + * statements with executable code between them, so "a repair snapshot was + * journalled" and "the engine said stranded" were not one fact. When the code + * between them threw, the snapshot existed and the verdict never shipped — + * and every consumer of the verdict derives repairability from it + * (`plugin-approvals` `approval-service.ts`: `repairable = status === + * 'stranded'`). So the operator was told `repairable: false` about a run that + * `restoreConsumedSuspension` puts back successfully. + * + * ⚠️ That direction is the whole point. The failure everybody checks for is a + * false `true` — promising a repair for a lost run. This is a false `false`: + * it stops someone from running a repair that WORKS. + * + * ## What is between the two statements + * + * `this.recordLog(...)` — synchronous and, at the time of this card, + * unguarded. Two of its own statements can throw out of it on the terminal + * path: the run-summary line (`this.logger.info(line, meta)`, on by default, + * `runSummaryLog: 'info'`) and `this.store.recordTerminal(record)`, whose + * SYNCHRONOUS throw escapes — the `void write.catch(...)` beneath it only ever + * sees a returned promise's rejection. Then `await this.failAncestors(...)`. + * + * ## What this file pins + * + * 1. **The window, driven both ways it is reachable** — a store whose + * `recordTerminal` throws synchronously, and a logger whose `info` throws + * (which needs no store at all, so it also proves the journal that makes + * the run repairable is in-memory and independent of the durable row). + * In both, `resume` RETURNS `status: 'stranded'` and the repair verb + * answers `restored: true` on that same run. + * 2. **The secondary failure stays loud.** The guard must not turn a lost + * history row into silence: it logs at `error`, naming the run, what was + * lost, and the verb that repairs the strand. + * 3. **Controls, so the stamp is a reading and not a constant** — a clean + * strand takes no guard and logs no error; a resume refused BEFORE the + * consumption point still carries NO status and leaves the pause live. + * ⛔ The guard must widen nothing: only the one exit that already + * journalled a snapshot may say `stranded`. + * + * ## Deliberately NOT here + * + * ⛔ Cascade-failed ancestors. They go through `failSuspendedRun`, which + * journals nothing, so `repairable: false` there is CORRECT and the repair + * verb rightly refuses with `NO_CONSUMED_SUSPENSION`. This file never asserts + * a stranded verdict for them. + * + * ⚠️ `repairable` is a point-in-time fact even when correct — an in-memory + * journal is evicted past `MAX_CONSUMED_SUSPENSIONS`, and PIN 1's own run has + * NO durable row (that is the failure being driven), so a restart loses the + * repair. That residual is recorded on #15555 as explicitly NOT this card, and + * nothing here promises otherwise: the pins assert the verb succeeds NOW, + * which is exactly what the operator was wrongly told not to try. + */ + +import { describe, it, expect } from 'vitest'; + +import { AutomationEngine } from './engine.js'; +import { InMemorySuspendedRunStore } from './suspended-run-store.js'; +import type { AutomationContext } from '@objectstack/spec/contracts'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; + +/** The secondary failure, distinct from the node's, so neither can stand in for the other. */ +const TERMINAL_WRITE_FAILURE = 'run-history driver refused the terminal row'; +const SUMMARY_LOG_FAILURE = 'log transport rejected the run-summary line'; +/** The node failure that consumes the pause and strands the run. */ +const NODE_FAILURE = 'tail blew up'; + +const holdDescriptor = defineActionDescriptor({ + type: 'hold', version: '1.0.0', name: 'hold', + supportsPause: true, resumeAuthority: 'any', +}); +const plain = (type: string) => defineActionDescriptor({ type, version: '1.0.0', name: type }); + +/** start → hold (pauses) → tail (throws on resume) → end. */ +const STRAND_FLOW = { + name: 'strand_flow', label: 'Strand', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'hold', type: 'hold', label: 'Hold' }, + { id: 'tail', type: 'tail', label: 'Tail' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'hold' }, + { id: 'e2', source: 'hold', target: 'tail' }, + { id: 'e3', source: 'tail', target: 'end' }, + ], +}; + +const ctx = { event: 'test', record: { id: 'rec_1' } } as unknown as AutomationContext; + +interface LoggedError { message: string; errorSlot: unknown; meta: unknown } + +/** + * A logger that records `error` calls positionally (the `Logger` contract is + * `error(message, error?, meta?)`) and can be made to throw from `info`. + */ +function recorder(opts: { infoThrows?: string } = {}) { + const errors: LoggedError[] = []; + return { + errors, + logger: { + // Armed at exactly ONE call: the run-summary line `recordLog` + // writes for a TERMINAL run (`meta.status`), which is the + // statement inside the window. The engine also logs `info` while + // registering executors and while running, and throwing from + // those would measure a different seam entirely. + info(_msg: string, meta?: { status?: string }) { + if (opts.infoThrows && meta?.status === 'failed') throw new Error(opts.infoThrows); + }, + warn() {}, + debug() {}, + error(message: string, errorSlot?: unknown, meta?: unknown) { + errors.push({ message, errorSlot, meta }); + }, + } as never, + }; +} + +/** A durable store whose terminal write throws SYNCHRONOUSLY, before any promise exists. */ +class SyncThrowTerminalStore extends InMemorySuspendedRunStore { + override recordTerminal(): Promise { + throw new Error(TERMINAL_WRITE_FAILURE); + } +} + +function engineOver(store: InMemorySuspendedRunStore | undefined, logger: never) { + const led = { tail: 0 }; + const knobs = { throws: true }; + const engine = new AutomationEngine(logger, store); + engine.registerNodeExecutor({ + type: 'hold', + descriptor: holdDescriptor, + async execute() { return { success: true, suspend: true, correlation: 'approval:req_1' }; }, + } as never); + engine.registerNodeExecutor({ + type: 'tail', + descriptor: plain('tail'), + async execute() { + led.tail++; + if (knobs.throws) throw new Error(NODE_FAILURE); + return { success: true, output: { done: true } }; + }, + } as never); + engine.registerFlow('strand_flow', STRAND_FLOW as never); + return { engine, led, knobs }; +} + +/** + * Resume, recording WHICH WAY the call ended. The defect's whole shape is that + * `resume` threw where it should have returned, so a plain `await` would fail + * the test with a stack trace instead of a reading. + */ +async function resumeOutcome(engine: AutomationEngine, runId: string) { + return engine.resume(runId).then( + result => ({ kind: 'returned' as const, result, thrown: undefined }), + (err: unknown) => ({ kind: 'threw' as const, result: undefined, thrown: err }), + ); +} + +async function park(engine: AutomationEngine) { + const started = await engine.execute('strand_flow', ctx); + expect(started.status).toBe('paused'); + return started.runId as string; +} + +describe('#15555 — a throw between the journal and the stamp must not lose the strand verdict', () => { + it('PIN 1 — the durable terminal write throws synchronously: the verdict still ships, and the repair works', async () => { + const store = new SyncThrowTerminalStore(); + const { logger } = recorder(); + const { engine, led } = engineOver(store, logger); + const runId = await park(engine); + + const outcome = await resumeOutcome(engine, runId); + + // ── The reproduction. Before the guard this was `'threw'`, carrying + // the run-history failure and NO run-state discriminator at all, so + // every consumer of `AutomationResult.status` saw nothing. + expect(outcome.kind, 'resume must REPORT the strand, not throw the secondary failure').toBe('returned'); + expect(outcome.result?.success).toBe(false); + expect(outcome.result?.status, "the producer's discriminator, #13937 shape 4").toBe('stranded'); + // The run's OWN failure is what the caller is told about — the node + // text, not the history driver's. The secondary failure is an operator + // fact and goes to the log, below. + expect(outcome.result?.error).toContain(NODE_FAILURE); + expect(outcome.result?.error).not.toContain(TERMINAL_WRITE_FAILURE); + expect(led.tail).toBe(1); + + // ── And the verdict is TRUE: the run is exactly the one the operator + // verb accepts. These two assertions together are what make the old + // `repairable: false` a false NEGATIVE rather than a safe default. + expect(await engine.hasSuspendedRun(runId)).toBe(false); + expect((await engine.resume(runId)).code).toBe('RUN_NOT_FOUND'); + const restored = await engine.restoreConsumedSuspension(runId, { requestedBy: 'ops' }); + expect(restored.restored, 'the repair the operator was told not to attempt').toBe(true); + expect(restored.refusal).toBeUndefined(); + expect(await engine.hasSuspendedRun(runId)).toBe(true); + + // ── The secondary failure was REAL, not simulated away: no durable + // history row landed. The repair rides on the in-memory journal, which + // is written before the row and survives its loss. + expect(await store.loadTerminal(runId), 'the history row genuinely did not land').toBeFalsy(); + }); + + it('PIN 2 — the run-summary log line throws, with NO store attached: same verdict, same repair', async () => { + // A second, independent statement in the same window, and one that + // needs no durable store at all — so this also shows the journal that + // makes the run repairable is the in-memory one. + const { errors, logger } = recorder({ infoThrows: SUMMARY_LOG_FAILURE }); + const { engine } = engineOver(undefined, logger); + const runId = await park(engine); + + const outcome = await resumeOutcome(engine, runId); + + expect(outcome.kind).toBe('returned'); + expect(outcome.result?.status).toBe('stranded'); + expect(outcome.result?.error).toContain(NODE_FAILURE); + expect((await engine.restoreConsumedSuspension(runId)).restored).toBe(true); + + expect(errors.length, 'the swallowed throw is still reported').toBe(1); + expect(errors[0]?.message).toContain(runId); + }); + + it('PIN 3 — the swallowed secondary failure is loud: `error`, naming the run and its repair verb', async () => { + // ⛔ The guard must not trade a lost verdict for a silent failure. + // AGENTS.md "Degradation log levels": the terminal history row claims + // to persist and did not, while the caller reads a clean strand — the + // judgment question answers YES, so `error`, with the consequence and + // the fix in the first line. + const { errors, logger } = recorder(); + const { engine } = engineOver(new SyncThrowTerminalStore(), logger); + const runId = await park(engine); + + await resumeOutcome(engine, runId); + + expect(errors.length).toBe(1); + const line = errors[0]!; + expect(line.message).toContain(runId); + expect(line.message, 'the consequence: the verdict shipped, the bookkeeping did not').toMatch(/stranded/i); + expect(line.message, 'the fix an operator can act on').toContain('restoreConsumedSuspension'); + // THIRD argument per `error(message, error?, meta?)` — the driver text + // goes to the structured slot, never into the message (#6499). + expect(line.errorSlot, 'the Error slot stays empty (#5575)').toBeUndefined(); + expect(JSON.stringify(line.meta)).toContain(TERMINAL_WRITE_FAILURE); + expect(line.message).not.toContain(TERMINAL_WRITE_FAILURE); + }); + + it('CONTROL — a clean strand is unchanged: same verdict, and NO error is logged', async () => { + // The reverse control for PIN 3. If this logged too, PIN 3 would be + // measuring "the engine logs on every strand", not "the guard fired". + const { errors, logger } = recorder(); + const { engine } = engineOver(new InMemorySuspendedRunStore(), logger); + const runId = await park(engine); + + const outcome = await resumeOutcome(engine, runId); + + expect(outcome.kind).toBe('returned'); + expect(outcome.result?.status).toBe('stranded'); + expect((await engine.restoreConsumedSuspension(runId)).restored).toBe(true); + expect(errors, 'no secondary failure ⇒ nothing to report').toEqual([]); + }); + + it('CONTROL — the guard widens nothing: a resume refused BEFORE the consumption point still carries no status', async () => { + // ⛔ `stranded` stays the name of exactly one exit — the one that + // journalled a snapshot. An exit above the consumption point has + // nothing to repair and must keep saying so. + const { errors, logger } = recorder(); + const { engine, led } = engineOver(new SyncThrowTerminalStore(), logger); + const runId = await park(engine); + + const refused = await engine.resume(runId, { variables: { $internal: 1 } } as never); + expect(refused.success).toBe(false); + expect(refused.code).toBe('INVALID_SIGNAL'); + expect(refused.status, 'no journal ⇒ no strand verdict').toBeUndefined(); + expect(led.tail).toBe(0); + expect(await engine.hasSuspendedRun(runId)).toBe(true); + expect((await engine.restoreConsumedSuspension(runId)).refusal).toBe('RUN_SUSPENDED'); + expect(errors).toEqual([]); + + // …and a run resumed cleanly still carries no status at all. Driven on + // a HEALTHY store on purpose: the completion path has its own + // unguarded `recordLog`, and a store that throws there makes `resume` + // throw over a run that COMPLETED — a different exit, a different + // card, and filed separately rather than widened into this guard. + const clean = engineOver(new InMemorySuspendedRunStore(), recorder().logger); + clean.knobs.throws = false; + const cleanRunId = await park(clean.engine); + const done = await resumeOutcome(clean.engine, cleanRunId); + expect(done.kind).toBe('returned'); + expect(done.result?.success).toBe(true); + expect(done.result?.status, 'a completed run is never stranded').toBeUndefined(); + expect(clean.led.tail).toBe(1); + }); +});