Skip to content

Commit 775e5ec

Browse files
os-trumpclaude
andauthored
fix(service-automation): persist the terminal run status distinction (cancelled / timed_out survive a restart) (#17008)
* wip(service-automation): widen the persisted terminal run status to four members Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * fix(service-automation): persist the terminal run status distinction `RunRecord.status` declared two members while `recordLog`'s terminal predicate admitted four and `ExecutionStatus` declares them all. Both ends of the durable store folded to match the narrower declaration, so a cancelled or timed-out run's distinction was destroyed at write time: the same run read `cancelled` in-process and `failed` after a restart. - write side: `recordLog` records the status its own terminal predicate admitted, resolved once through the newly declared `TERMINAL_RUN_STATUSES` - read side: the row's status is resolved once in the terminal gate and handed to `deserializeTerminal`, which no longer folds it; `listHistory`'s second copy of the two-member list now asks the same predicate - stored column: `sys_automation_run.status` options and the retention `onlyWhen` scope carry all four terminal members - pins: the distinction survives a fresh store over the same rows, on `getRun`, `listRuns` (with its `?status=` filter) and `listHistory` Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * docs(changeset): state the non-breaking conclusion without the declaration token The body explained why no breaking-change banner is owed, and spelled the token to say so — which `check:adr-0087-registration` reads as the declaration itself (its detector is token-based and blind to the negation, the same shape as the closing-keyword trap). The reasoning is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d127f9b commit 775e5ec

7 files changed

Lines changed: 289 additions & 29 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
---
4+
5+
A run's durable history row records the terminal status the run actually reached — `completed`, `failed`, `cancelled` or `timed_out` — instead of folding all four into two. A restart no longer changes a run's answer.
6+
7+
`RunRecord.status` declared two members (`'completed' | 'failed'`) while `AutomationEngine.recordLog`'s own terminal predicate admitted four and `ExecutionStatus` (`@objectstack/spec`) has declared them all along. Both ends of the store folded to match the narrower declaration: the write mapped everything that was not `completed` to `failed`, and the read mapped everything that was not `failed` back to `completed`. The distinction was therefore not hidden — it was **destroyed at write time**, so no later change could recover it for a row already stored. The cost was that one run answered differently depending on where you read it: `getRun` prefers the in-memory ring entry and said `cancelled`, while after a restart or a ring-buffer eviction the durable row answered, and it said `failed`.
8+
9+
- **The write side.** `recordLog` writes the status its own terminal predicate admitted, resolved once into a `const` that also decides whether a row is written at all. The predicate is now the single declared vocabulary, `TERMINAL_RUN_STATUSES` (`engine.ts`) — three sites had a copy of that list and only one of them was ever going to be updated together with the writer.
10+
- **The read side.** `ObjectStoreSuspendedRunStore` resolves the row's status once in the gate that already decided whether the row is terminal at all and hands the member to `deserializeTerminal`, which no longer re-reads or folds it. `listHistory`'s filter was the second copy of the two-member list — left alone it would have replaced a wrong status with a *missing row*, dropping cancelled runs out of the Runs list entirely.
11+
- **The stored column.** `sys_automation_run.status` accepts the two added members, and the retention scope (`lifecycle.retention.onlyWhen`) counts them as terminal — a widened writer over a two-member sweep scope would have left `cancelled` and `timed_out` history rows never ageing out, on a table whose whole retention posture (ADR-0057) is that history is telemetry. `refused` is deliberately not added: `ExecutionStatus` declares it (#14945) but no engine path produces it, and an option nothing can write is declared-but-inert metadata (ADR-0078).
12+
- **Rows already stored keep reading `failed`.** The information they lost is not recoverable and this change does not pretend otherwise — there is no backfill, because there is nothing to backfill *from*. Rows written from this release forward carry the distinction.
13+
- **`TerminalRunStatus`** is exported for the same reason `ConsumedSuspensionDropNotice` is: `RunRecord` is barrel-reachable, and a host store implementing `recordTerminal` / `loadTerminal` has to be able to name the field it round-trips.
14+
15+
Not a breaking change, and deliberately carries no breaking-change banner: the published contract (`IAutomationService.getRun` / `listRuns` return `ExecutionLog`, whose `status` is `ExecutionStatus`) has declared all four members since before this row existed. What changes is that the implementation stops under-reporting one the contract already promised — a consumer written against the declared contract is unaffected. Also no ADR-0087 migration entry: that ADR governs authorable metadata shapes on `sys_metadata`, and this is an engine-owned system data table whose existing values stay valid under the widened option set.

packages/services/service-automation/src/engine.ts

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,11 +1213,58 @@ export interface SuspendedRun {
12131213
* A terminal run summary persisted as durable run history (completed / failed)
12141214
* for the "Runs" observability surface — distinct from a live {@link SuspendedRun}.
12151215
*/
1216+
/**
1217+
* [#15223] The terminal states a run can be RECORDED in — the durable
1218+
* run-history vocabulary, declared ONCE because three sites have to agree on
1219+
* it: the writer ({@link AutomationEngine.recordLog}'s terminal predicate),
1220+
* the reader (`ObjectStoreSuspendedRunStore`'s row gate) and the stored
1221+
* column (`sys_automation_run.status`, whose `Field.select` options and
1222+
* retention `onlyWhen` scope enumerate the same four). A second copy of this
1223+
* list is how a widened writer ends up with rows a reader filters away.
1224+
*
1225+
* These are exactly the four `ExecutionStatus` members (`@objectstack/spec`)
1226+
* that mean "this run has stopped and will not resume". `paused`,
1227+
* `running`, `pending` and `retrying` are live states with no history row;
1228+
* `refused` is declared by the spec but no engine path produces it today, so
1229+
* adding it here would enumerate a value nothing can write.
1230+
*/
1231+
export const TERMINAL_RUN_STATUSES = ['completed', 'failed', 'cancelled', 'timed_out'] as const;
1232+
1233+
/** One member of {@link TERMINAL_RUN_STATUSES}. */
1234+
export type TerminalRunStatus = (typeof TERMINAL_RUN_STATUSES)[number];
1235+
1236+
/** Whether `status` is one of {@link TERMINAL_RUN_STATUSES}. */
1237+
export function isTerminalRunStatus(status: unknown): status is TerminalRunStatus {
1238+
return (TERMINAL_RUN_STATUSES as readonly unknown[]).includes(status);
1239+
}
1240+
12161241
export interface RunRecord {
12171242
runId: string;
12181243
flowName: string;
12191244
flowVersion?: number;
1220-
status: 'completed' | 'failed';
1245+
/**
1246+
* The terminal state this run reached, as the engine observed it.
1247+
*
1248+
* [#15223] This declared `'completed' | 'failed'` — TWO members — while
1249+
* {@link AutomationEngine.recordLog}'s own terminal predicate admitted
1250+
* FOUR and `ExecutionStatus` (`@objectstack/spec`) declared them all.
1251+
* Both ends folded: the write mapped everything that was not `completed`
1252+
* to `failed`, and the read mapped everything that was not `failed` to
1253+
* `completed`. The information was not hidden by that, it was DESTROYED
1254+
* at write time, so the same run read `cancelled` in-process (`getRun`
1255+
* prefers the ring entry) and `failed` after a restart or a ring
1256+
* eviction.
1257+
*
1258+
* ⛔ The narrowing was INHERITED, not chosen — recorded here so the next
1259+
* reader does not re-derive it. Nothing was paying for it: the column is a
1260+
* `Field.select` that stores the string whatever its width, so there was
1261+
* no storage cost to buy, and neither file stated a reason. It is simply
1262+
* older than what it had to carry — the durable history row (#2585)
1263+
* predates `cancelRun` (ADR-0044), and `timed_out` was in the spec's
1264+
* vocabulary the whole time. ⛔ Do not re-narrow it to make a downstream
1265+
* `switch` exhaustive; widen the switch.
1266+
*/
1267+
status: TerminalRunStatus;
12211268
startedAt: string;
12221269
startTime?: number;
12231270
/** When the run reached its terminal state. */
@@ -4190,7 +4237,11 @@ export class AutomationEngine implements IAutomationService {
41904237
id: r.runId,
41914238
flowName: r.flowName,
41924239
flowVersion: r.flowVersion,
4193-
status: r.status, // 'completed' | 'failed' — both valid ExecutionLog statuses
4240+
// [#15223] All four {@link TERMINAL_RUN_STATUSES} members, each a
4241+
// valid `ExecutionLog` status — the schema has declared the whole
4242+
// vocabulary since before this row existed, and it was the
4243+
// persistence layer, not the contract, that reported only two.
4244+
status: r.status,
41944245
startedAt: r.startedAt,
41954246
completedAt: r.finishedAt,
41964247
durationMs: r.durationMs,
@@ -7374,11 +7425,13 @@ export class AutomationEngine implements IAutomationService {
73747425
// store so "did it run / fail, and why?" survives a restart and the
73757426
// in-memory ring-buffer eviction. Best-effort + fire-and-forget: a
73767427
// history write must NEVER block or break the run that produced it.
7377-
const terminal =
7378-
entry.status === 'completed' ||
7379-
entry.status === 'failed' ||
7380-
entry.status === 'cancelled' ||
7381-
entry.status === 'timed_out';
7428+
// [#15223] ONE vocabulary, not a fourth copy of the list: this
7429+
// predicate decides both WHETHER a history row is written and WHAT its
7430+
// `status` says. Keeping the narrowed value in a `const` is what makes
7431+
// the record below type-check without a cast — and a cast is precisely
7432+
// how the fold this card is about survived four members for two.
7433+
const terminalStatus = isTerminalRunStatus(entry.status) ? entry.status : undefined;
7434+
const terminal = terminalStatus !== undefined;
73827435

73837436
// The MVP of #4354, and the half that needs no console: one structured
73847437
// line per terminal run. `selected=30 acted=0` in a log file is the
@@ -7413,13 +7466,20 @@ export class AutomationEngine implements IAutomationService {
74137466
else this.logger.info(line, meta);
74147467
}
74157468

7416-
if (terminal && this.store?.recordTerminal) {
7469+
if (terminalStatus && this.store?.recordTerminal) {
74177470
const lastStep = entry.steps[entry.steps.length - 1];
74187471
const record: RunRecord = {
74197472
runId: entry.id,
74207473
flowName: entry.flowName,
74217474
flowVersion: entry.flowVersion,
7422-
status: entry.status === 'completed' ? 'completed' : 'failed',
7475+
// [#15223] The status the run actually reached. This used to be
7476+
// `entry.status === 'completed' ? 'completed' : 'failed'` — a
7477+
// fold applied at WRITE time, so a cancelled or timed-out run's
7478+
// distinction was not merely unshown, it was never stored and
7479+
// could not be recovered afterwards. ⛔ Never re-introduce a
7480+
// conditional here: whatever the terminal predicate above
7481+
// admits is what the row must carry.
7482+
status: terminalStatus,
74237483
startedAt: entry.startedAt,
74247484
finishedAt: entry.completedAt,
74257485
durationMs: entry.durationMs,

packages/services/service-automation/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export type {
5858
// host store implementing `recordTerminal` / `loadTerminal` writes and
5959
// reads; unnameable, the field would be writable only by structural luck.
6060
ConsumedSuspensionDropNotice,
61+
// [#15223] The type of `RunRecord.status`, exported for exactly the reason
62+
// above: `RunRecord` is barrel-reachable and a host store implementing
63+
// `recordTerminal` / `loadTerminal` has to name the field it round-trips.
64+
// It is also the set to switch over — a terminal run's four states exist
65+
// precisely to be told apart, which is the whole of what this card fixed.
66+
TerminalRunStatus,
6167
// [#15358] The read-only repairability verdict
6268
// (`AutomationEngine.inspectConsumedSuspension`), for the same reason as
6369
// `SuspensionRestoreResult` above: the method is barrel-reachable, so a

packages/services/service-automation/src/stranded-run-status.test.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@
3838
* 6. **The recorded `ExecutionStatus` stays `failed`** — the ruling widened
3939
* the RESULT vocabulary; the run-row vocabulary is `@objectstack/spec`'s
4040
* (`automation/execution.zod.ts`) and is untouched, in the log and in the
41-
* durable history row.
41+
* durable history row. [#15223] Still true of a STRANDED run, which is
42+
* recorded `failed`. What changed under this file is a different run: a
43+
* CANCELLED one, whose durable row used to be folded to `failed` on the
44+
* way in and now carries `cancelled`. See case 4's second test.
4245
*/
4346

4447
import { describe, it, expect } from 'vitest';
@@ -334,10 +337,23 @@ describe('#13937 — a re-armed run is not double-runnable', () => {
334337

335338
const stale = await a.restoreConsumedSuspension(runId);
336339
expect(stale.restored).toBe(false);
337-
// A's own log still says `failed` for this run and the durable row
338-
// records a cancelled run as `failed` too, so A cannot name the
339-
// cancellation — what it CAN say, honestly, is that no snapshot is
340-
// held any more. ⛔ Never `RUN_SUSPENDED`, and never `restored: true`.
340+
// A's own log still says `failed` for this run, and `getRun` prefers
341+
// the ring entry — so A cannot name the cancellation and says,
342+
// honestly, that no snapshot is held any more. ⛔ Never
343+
// `RUN_SUSPENDED`, and never `restored: true`.
344+
//
345+
// [#15223] ⚠️ The REASON narrowed here, and the assertion is kept to
346+
// pin the half that did not move. It used to hold for two reasons —
347+
// A's stale ring entry AND a durable row that recorded every
348+
// cancellation as `failed`. The row carries `cancelled` now
349+
// (`suspended-run-store.test.ts`, "the persisted terminal status
350+
// distinction"), and a replica with NO ring entry for this run answers
351+
// `RUN_CANCELLED` from it. What still produces `NO_CONSUMED_SUSPENSION`
352+
// is only A's own stale hot copy shadowing the row: the ladder tests
353+
// `cancelled` against `getRun` (ring first) while consulting the
354+
// durable row for `completed` alone. ⛔ Deliberately NOT changed by
355+
// #15223 — triage ruled the ladder honest and the row the defect; this
356+
// is the measured residue, recorded so it is not mistaken for a fix.
341357
expect(stale.refusal).toBe('NO_CONSUMED_SUSPENSION');
342358
expect(await store.list()).toHaveLength(0);
343359
expect((await a.resume(runId)).code).toBe('RUN_NOT_FOUND');

packages/services/service-automation/src/suspended-run-store.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,3 +1051,116 @@ describe('#14333 ObjectStoreSuspendedRunStore.claimSuspension — the production
10511051
expect(dataEngine.rows.get(runId).node_id).toBe('lv1');
10521052
});
10531053
});
1054+
1055+
/**
1056+
* #15223 — the persisted terminal-status distinction.
1057+
*
1058+
* `RunRecord.status` declared two members (`'completed' | 'failed'`) while
1059+
* `recordLog`'s own terminal predicate admitted four and `ExecutionStatus`
1060+
* (`@objectstack/spec`) declared them all. Both ends of this store folded to
1061+
* match the narrower declaration — the write mapped everything that was not
1062+
* `completed` to `failed`, the read mapped everything that was not `failed`
1063+
* to `completed` — so a cancelled run's distinction was not merely unshown:
1064+
* it was **destroyed at write time**, and no later fix could recover it for a
1065+
* row already stored.
1066+
*
1067+
* ⭐ The property, and the reason every assertion below reads through a
1068+
* SECOND store over the same rows: the defect is invisible in-process.
1069+
* `getRun` prefers the in-memory ring entry, which has always said
1070+
* `cancelled`; only a restart (or a ring eviction) makes the row answer. An
1071+
* in-process assertion cannot see this and would have stayed green throughout.
1072+
*
1073+
* The three surfaces a restart moved, pinned here together because the fold
1074+
* lived at one site and surfaced at all three: `getRun`, `listRuns` (including
1075+
* its wire-exposed `?status=` filter, #7359) and `listHistory`.
1076+
*/
1077+
describe('ObjectStoreSuspendedRunStore — the persisted terminal status distinction (#15223)', () => {
1078+
/** Land the fire-and-forget `recordTerminal` off the terminal `recordLog`. */
1079+
const settle = () => new Promise((r) => setImmediate(r));
1080+
1081+
it('⭐ a CANCELLED run still reads `cancelled` from a process that never saw the cancel', async () => {
1082+
const table = createFakeEngine();
1083+
const freshStore = () => new ObjectStoreSuspendedRunStore(table, createTestLogger());
1084+
1085+
// Replica A parks the run, then an operator ends it deliberately
1086+
// (`cancelRun`, ADR-0044).
1087+
const a = pausableEngine(freshStore());
1088+
const paused = await a.execute('approval_flow');
1089+
const runId = paused.runId!;
1090+
expect(await a.cancelRun(runId, 'submitter withdrew')).toBe(true);
1091+
await settle();
1092+
1093+
// In-process this has always worked — it is the ring entry answering.
1094+
expect((await a.getRun(runId))?.status).toBe('cancelled');
1095+
1096+
// ⭐ The ROW, which is the whole of what a restart leaves behind. This
1097+
// said `failed` before the write-side fold was removed, and nothing
1098+
// downstream could have recovered the cancellation from it.
1099+
expect(table.rows.get(`run_${runId}`)?.status).toBe('cancelled');
1100+
1101+
// ⭐ …and a fresh process over the same rows — empty ring, new store —
1102+
// now answers what the operator actually did, on both read surfaces.
1103+
const b = pausableEngine(freshStore());
1104+
expect((await b.getRun(runId))?.status).toBe('cancelled');
1105+
expect((await b.listRuns('approval_flow')).find(r => r.id === runId)?.status).toBe('cancelled');
1106+
1107+
// The wire's `?status=` filter (#7359) reads the same resolved status,
1108+
// so it stops answering the opposite of the truth: the cancelled run
1109+
// used to be what `?status=failed` returned and `?status=cancelled`
1110+
// could not return at all.
1111+
expect((await b.listRuns('approval_flow', { status: 'cancelled' })).map(r => r.id)).toEqual([runId]);
1112+
expect(await b.listRuns('approval_flow', { status: 'failed' })).toEqual([]);
1113+
});
1114+
1115+
it('all four terminal members round-trip through the ROW — and none is filtered out of the history', async () => {
1116+
const table = createFakeEngine();
1117+
const writer = new ObjectStoreSuspendedRunStore(table, createTestLogger());
1118+
// `timed_out` has no engine path producing it today, so the store's own
1119+
// contract is where it can be measured at all — which is exactly why
1120+
// the vocabulary is declared once and asserted here rather than
1121+
// inferred from whatever the engine happens to emit.
1122+
const members = ['completed', 'failed', 'cancelled', 'timed_out'] as const;
1123+
for (const [i, status] of members.entries()) {
1124+
await writer.recordTerminal(terminalRecord(i + 1, { status, flowName: 'four_flow' }));
1125+
}
1126+
1127+
// The stored bytes carry the distinction — one row per member.
1128+
expect(members.map(s => [...table.rows.values()].filter(r => r.status === s).length)).toEqual([1, 1, 1, 1]);
1129+
1130+
// A FRESH store over the same rows reads each one back unchanged. The
1131+
// read-side fold made this collapse to `completed` for two of them.
1132+
const reader = new ObjectStoreSuspendedRunStore(table, createTestLogger());
1133+
for (const [i, status] of members.entries()) {
1134+
expect((await reader.loadTerminal(`r${i + 1}`))?.status).toBe(status);
1135+
}
1136+
1137+
// ⛔ And the list-side gate admits all four. This filter used to spell
1138+
// its own two-member list — a SECOND copy of the vocabulary — so
1139+
// widening only the writer would have replaced a wrong status with a
1140+
// missing row, which is worse: a cancelled run would have vanished
1141+
// from the Runs list entirely.
1142+
const history = await reader.listHistory('four_flow', 10);
1143+
expect(history.map(r => r.status).sort()).toEqual([...members].sort());
1144+
});
1145+
1146+
it('the refusal ladder answers RUN_CANCELLED to a replica that never saw the cancel', async () => {
1147+
// The reading triage asked for, and it is only half the story — see the
1148+
// companion pin in `stranded-run-status.test.ts`, where a replica
1149+
// holding its OWN stale `failed` ring entry still answers
1150+
// `NO_CONSUMED_SUSPENSION`. ⛔ The ladder is deliberately unchanged
1151+
// here; what moved is the row it reads.
1152+
const table = createFakeEngine();
1153+
const a = pausableEngine(new ObjectStoreSuspendedRunStore(table, createTestLogger()));
1154+
const paused = await a.execute('approval_flow');
1155+
const runId = paused.runId!;
1156+
expect(await a.cancelRun(runId, 'submitter withdrew')).toBe(true);
1157+
await settle();
1158+
1159+
const b = pausableEngine(new ObjectStoreSuspendedRunStore(table, createTestLogger()));
1160+
const refused = await b.restoreConsumedSuspension(runId);
1161+
expect(refused.restored).toBe(false);
1162+
// Was `NO_CONSUMED_SUSPENSION` — honest, but everything the folded row
1163+
// could support. The row can support the real reason now.
1164+
expect(refused.refusal).toBe('RUN_CANCELLED');
1165+
});
1166+
});

0 commit comments

Comments
 (0)