@signalridge/pi-workflows 1.3.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/journal.ts CHANGED
@@ -1,47 +1,58 @@
1
1
  /**
2
- * journal.ts — durable journal for script-based workflow runs (schema v3).
2
+ * journal.ts — durable journal for script-based workflow runs (schema v4).
3
3
  *
4
4
  * A script run is journaled as an ordered event stream in the session entry
5
5
  * store. `run_created` carries the raw script, its hash, and its parsed meta
6
6
  * (the runtime re-derives call indexes from the script itself, so no task
7
7
  * array is persisted). Each completed `agent()`/`checkpoint()` call journals a
8
- * `call_result` keyed by its lexical call index; a resume rebuilds the
8
+ * `call_result` keyed by its lexical call index; a nested workflow journals a
9
+ * `workflow_result` at its parent call index; a resume rebuilds the
9
10
  * runtime's `resumeJournal` map from these entries and replays the longest
10
- * unchanged prefix.
11
+ * unchanged prefix. Nested `workflow()` calls are represented by one
12
+ * `workflow_result` boundary in the parent namespace, so child calls can be
13
+ * replayed without re-spawning the whole nested run.
11
14
  *
12
15
  * The atomic recovery machinery (run/terminal/attempt recovery with generation
13
16
  * rotations, quarantine of malformed runs, stale-generation rejection) is
14
17
  * preserved from the declarative journal and re-keyed to call indexes, because
15
18
  * interrupted managed spawns still need generation rotation on restore.
16
19
  *
17
- * Schema v2 runs (declarative DAG journals) are quarantined rather than
18
- * replayed — the event shapes are incompatible and a script run must not
19
- * inherit DAG bookkeeping.
20
+ * Schema-v3 and older runs are quarantined rather than replayed — the call
21
+ * hash and metadata contracts changed, and a script run must not silently
22
+ * inherit incompatible bookkeeping.
20
23
  */
21
24
 
22
25
  import { createHash } from "node:crypto";
23
26
  import type { ManagedOwner } from "@signalridge/pi-subagents-protocol";
24
- import type { WorkflowMeta } from "./runtime.js";
27
+ import { isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
28
+ import {
29
+ MAX_CALLS_PER_WORKFLOW,
30
+ normalizeAgentRetries,
31
+ normalizeAgentTimeout,
32
+ normalizeConcurrency,
33
+ normalizeMaxAgents,
34
+ normalizeTokenBudget,
35
+ type WorkflowMeta,
36
+ } from "./runtime.js";
25
37
 
26
38
  export const JOURNAL_ENTRY_TYPE = "pi-workflows:journal";
27
- export const JOURNAL_SCHEMA_VERSION = 3;
28
- /** Schema v2 (declarative DAG) and v1 are quarantined on replay, not migrated. */
29
- export const JOURNAL_LEGACY_SCHEMA_VERSION = 2;
30
- export type JournalSchemaVersion = 2 | 3;
39
+ /** Breaking journal contract: v3 facts are quarantined, never replayed. */
40
+ export const JOURNAL_SCHEMA_VERSION = 4;
41
+ export type JournalSchemaVersion = 4;
31
42
  export const JOURNAL_TEXT_LIMIT = 8_000;
32
43
  export const JOURNAL_SCRIPT_LIMIT = 200_000;
33
44
  export const JOURNAL_ERROR_LIMIT = 2_000;
34
45
  export const JOURNAL_ID_LIMIT = 256;
35
46
  export const JOURNAL_TIMESTAMP_LIMIT = 8_640_000_000_000_000;
36
- export const JOURNAL_RECOVERY_ID_PREFIX = "r3-";
47
+ export const JOURNAL_RECOVERY_ID_PREFIX = "r4-";
37
48
  export const JOURNAL_RECOVERY_ID_LIMIT = JOURNAL_RECOVERY_ID_PREFIX.length + 64;
38
49
  /** Maximum number of distinct atomic recoveries retained while replaying one run. */
39
50
  export const JOURNAL_RECOVERY_SEEN_LIMIT = 4_096;
40
- /** A script run may issue up to 1000 agent()/checkpoint() calls. */
41
- export const JOURNAL_CALL_LIMIT = 1_000;
51
+ /** A script run may issue up to 1000 agent()/workflow()/checkpoint() calls. */
52
+ export const JOURNAL_CALL_LIMIT = MAX_CALLS_PER_WORKFLOW;
42
53
  /** One atomic recovery event may rotate every active call in a run. */
43
54
  export const JOURNAL_RECOVERY_NODE_LIMIT = JOURNAL_CALL_LIMIT;
44
- const RECOVERY_ID_PATTERN = /^r3-[0-9a-f]{64}$/;
55
+ const RECOVERY_ID_PATTERN = /^r4-[0-9a-f]{64}$/;
45
56
 
46
57
  export class StaleJournalGenerationError extends Error {
47
58
  constructor(message: string) {
@@ -86,12 +97,18 @@ const JOURNAL_KINDS = new Set([
86
97
  "call_transition",
87
98
  "call_attempt",
88
99
  "call_result",
100
+ "workflow_result",
89
101
  ]);
90
102
 
91
103
  export type WorkflowOwner = ManagedOwner;
92
104
 
105
+ /** The Agent tier one journaled call resolved to. */
106
+ export interface CallTierIdentity {
107
+ tier?: string;
108
+ }
109
+
93
110
  /** A completed agent()/checkpoint() call, journaled for resume replay. */
94
- export interface CallResult {
111
+ export interface CallResult extends CallTierIdentity {
95
112
  status: "completed" | "failed" | "stopped";
96
113
  agentId?: string;
97
114
  attemptId?: string;
@@ -110,8 +127,18 @@ export interface ScriptRun {
110
127
  script: string;
111
128
  scriptHash: string;
112
129
  meta: WorkflowMeta;
130
+ /** Frozen invocation input reused by automatic and manual resume. */
131
+ args?: unknown;
132
+ /** Explicit marker recording whether the current run was given args. */
133
+ frozenArgsPresent: boolean;
113
134
  /** Number of the latest edited-script revision. */
114
135
  revision?: number;
136
+ /**
137
+ * This run's script ships with the package. Frozen for the same reason
138
+ * `toolset` is: a resume must keep treating a shipped script's tier names as
139
+ * preferences, not as assertions about the user's catalogue.
140
+ */
141
+ shippedScript?: boolean;
115
142
  /** Toolset marker persisted across resume (e.g. "web-research" for deep-research). */
116
143
  toolset?: string;
117
144
  /** Frozen run params — persisted so resume after restore keeps original budget/scale. */
@@ -131,10 +158,16 @@ export interface ScriptRun {
131
158
  attemptTracking?: boolean;
132
159
  /** Call index → journaled result (the resume journal source). */
133
160
  callResults: Record<string, CallResult>;
161
+ /** Call index → resolved tier identity for live and stopped calls. */
162
+ callTiers: Record<string, CallTierIdentity>;
163
+ /** Call index → latest accepted nested workflow result generation. */
164
+ workflowResultGenerations: Record<string, number>;
134
165
  compactions: Record<string, number>;
135
166
  startedAt: number;
136
167
  updatedAt: number;
137
168
  error?: string;
169
+ /** Bounded JSON-serializable terminal script value for restored inspection. */
170
+ finalResult?: unknown;
138
171
  nonResumable?: boolean;
139
172
  /** Terminal cleanup intent while active agents settle. */
140
173
  terminalIntent?: "stop" | "failure";
@@ -149,7 +182,7 @@ interface JournalBase {
149
182
  /** State observed immediately before an atomic recovery rotation. */
150
183
  export type RecoverySourceStatus = CallStatus | "running";
151
184
 
152
- export interface RecoveryRotation {
185
+ export interface RecoveryRotation extends CallTierIdentity {
153
186
  nodeId: string;
154
187
  sourceAttemptId: string;
155
188
  sourceGeneration: number;
@@ -174,8 +207,6 @@ export interface RunRecoveryEvent extends JournalBase {
174
207
  rotations: RecoveryRotation[];
175
208
  terminalResults?: RecoveryTerminalResult[];
176
209
  recoveryId: string;
177
- /** Set only when reading a schema-v2 atomic event written before recoveryId existed. */
178
- legacyMigration?: true;
179
210
  timestamp: number;
180
211
  }
181
212
 
@@ -196,8 +227,6 @@ export interface AttemptRecoveryEvent extends JournalBase {
196
227
  nodeId: string;
197
228
  branchGeneration: number;
198
229
  rotation: RecoveryRotation;
199
- /** Only used to migrate a legacy prefix ending after workflow_transition. */
200
- legacyMigration?: true;
201
230
  }
202
231
 
203
232
  export type JournalEvent =
@@ -206,6 +235,10 @@ export type JournalEvent =
206
235
  script: string;
207
236
  scriptHash: string;
208
237
  meta: WorkflowMeta;
238
+ args?: unknown;
239
+ /** Explicit args presence marker for deterministic resume. */
240
+ frozenArgsPresent: boolean;
241
+ shippedScript?: boolean;
209
242
  toolset?: string;
210
243
  frozenMaxAgents?: number;
211
244
  frozenConcurrency?: number;
@@ -230,6 +263,7 @@ export type JournalEvent =
230
263
  kind: "workflow_transition";
231
264
  status: WorkflowStatus;
232
265
  error?: string;
266
+ finalResult?: unknown;
233
267
  terminalIntent?: "stop" | "failure";
234
268
  })
235
269
  | RunRecoveryEvent
@@ -239,6 +273,7 @@ export type JournalEvent =
239
273
  kind: "call_transition";
240
274
  nodeId: string;
241
275
  status: CallStatus;
276
+ tier?: string;
242
277
  agentId?: string;
243
278
  attemptId?: string;
244
279
  owner?: WorkflowOwner;
@@ -248,6 +283,7 @@ export type JournalEvent =
248
283
  nodeId: string;
249
284
  attemptId: string;
250
285
  generation: number;
286
+ tier?: string;
251
287
  owner?: WorkflowOwner;
252
288
  })
253
289
  | (JournalBase & {
@@ -260,6 +296,20 @@ export type JournalEvent =
260
296
  storeDelta?: Record<string, unknown>;
261
297
  attemptId?: string;
262
298
  owner?: WorkflowOwner;
299
+ })
300
+ | (JournalBase & {
301
+ /** A completed nested workflow result, without managed-spawn attempt identity. */
302
+ kind: "workflow_result";
303
+ nodeId: string;
304
+ result: CallResult;
305
+ /** Monotonic nested-call generation used to fence changed/resumed children. */
306
+ generation: number;
307
+ /** Logical agents represented by this nested replay boundary. */
308
+ agentCount?: number;
309
+ /** Stable hash of the nested workflow name/script/args. */
310
+ callHash: string;
311
+ /** Store writes produced by the child, merged in child call order. */
312
+ storeDelta?: Record<string, unknown>;
263
313
  });
264
314
 
265
315
  export interface JournalWriter {
@@ -274,6 +324,8 @@ export interface SessionEntryLike {
274
324
 
275
325
  export interface ReplayJournalOptions {
276
326
  onInvalid?: (diagnostic: string) => void;
327
+ /** Internal observer for events that passed replay validation and were applied. */
328
+ onAccepted?: (event: JournalEvent) => void;
277
329
  }
278
330
 
279
331
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -302,27 +354,49 @@ function parseCallIndex(value: unknown, label: string): string {
302
354
  return index;
303
355
  }
304
356
 
357
+ function parsePersistedValue(raw: unknown, label: string): unknown {
358
+ try {
359
+ const cloned = structuredClone(raw);
360
+ const serialized = JSON.stringify(cloned);
361
+ if (serialized === undefined) throw new Error(`${label} is not JSON-serializable`);
362
+ if (serialized.length > JOURNAL_SCRIPT_LIMIT) throw new Error(`${label} exceeds the persistence limit`);
363
+ return cloned;
364
+ } catch (error: unknown) {
365
+ throw new Error(error instanceof Error ? error.message : `${label} is not structured-cloneable`);
366
+ }
367
+ }
368
+
305
369
  function parseMeta(raw: unknown): WorkflowMeta {
306
370
  if (!isRecord(raw)) throw new Error("meta is invalid");
307
371
  const name = boundedString(raw.name, "meta.name", 512);
308
372
  const description = boundedString(raw.description, "meta.description", 100_000);
373
+ if (Object.hasOwn(raw, "model") || Object.hasOwn(raw, "thinking")) {
374
+ throw new Error("workflow journal meta model/thinking fields are unsupported");
375
+ }
309
376
  const meta: WorkflowMeta = { name, description };
310
- if (raw.model !== undefined) meta.model = boundedString(raw.model, "meta.model", 512);
311
377
  if (raw.phases !== undefined) {
312
378
  if (!Array.isArray(raw.phases)) throw new Error("meta.phases must be an array");
313
379
  meta.phases = raw.phases.map((phase, i) => {
314
380
  if (!isRecord(phase) || typeof phase.title !== "string" || phase.title.length === 0) {
315
381
  throw new Error(`meta.phases[${i}].title is invalid`);
316
382
  }
383
+ if (Object.hasOwn(phase, "model") || Object.hasOwn(phase, "thinking")) {
384
+ throw new Error(`workflow journal phase ${i} model/thinking fields are unsupported`);
385
+ }
317
386
  const out: NonNullable<WorkflowMeta["phases"]>[number] = { title: phase.title.slice(0, 512) };
318
387
  if (phase.detail !== undefined) out.detail = String(phase.detail).slice(0, 2_000);
319
- if (phase.model !== undefined) out.model = String(phase.model).slice(0, 512);
320
388
  return out;
321
389
  });
322
390
  }
323
391
  return meta;
324
392
  }
325
393
 
394
+ function parseCallTierIdentity(raw: Record<string, unknown>, label: string): CallTierIdentity {
395
+ if (raw.tier === undefined) return {};
396
+ if (!isManagedAgentTier(raw.tier)) throw new Error(`${label}.tier is invalid`);
397
+ return { tier: raw.tier };
398
+ }
399
+
326
400
  function parseResult(raw: unknown, label: string): CallResult {
327
401
  if (!isRecord(raw)) throw new Error(`${label} is invalid`);
328
402
  const status = raw.status;
@@ -334,10 +408,12 @@ function parseResult(raw: unknown, label: string): CallResult {
334
408
  throw new Error(`${label} compactionCount is invalid`);
335
409
  }
336
410
  const updatedAt = validTimestamp(raw.updatedAt, label);
411
+ const tierIdentity = parseCallTierIdentity(raw, label);
337
412
  const result: CallResult = {
338
413
  status,
339
414
  compactionCount,
340
415
  updatedAt,
416
+ ...tierIdentity,
341
417
  ...(raw.agentId === undefined ? {} : { agentId: boundedString(raw.agentId, `${label}.agentId`, JOURNAL_ID_LIMIT) }),
342
418
  ...(raw.attemptId === undefined
343
419
  ? {}
@@ -361,6 +437,17 @@ function parseResult(raw: unknown, label: string): CallResult {
361
437
  return result;
362
438
  }
363
439
 
440
+ function mergeCallResultTierIdentity(previous: CallTierIdentity | undefined, result: CallResult): CallResult {
441
+ return result.tier === undefined && previous?.tier !== undefined ? { ...result, tier: previous.tier } : result;
442
+ }
443
+
444
+ function priorCallTierIdentity(run: ScriptRun, nodeId: string): CallTierIdentity {
445
+ return {
446
+ ...(run.callTiers[nodeId] ?? {}),
447
+ ...(run.callResults[nodeId]?.tier === undefined ? {} : { tier: run.callResults[nodeId].tier }),
448
+ };
449
+ }
450
+
364
451
  function parseOwner(raw: unknown, runId: string, nodeId: string): WorkflowOwner {
365
452
  if (!isRecord(raw) || raw.extension !== "pi-workflows") throw new Error("journal owner is invalid");
366
453
  const ownerRunId = boundedString(raw.runId, "owner.runId", JOURNAL_ID_LIMIT);
@@ -430,6 +517,7 @@ function parseRotation(raw: unknown, runId: string): RecoveryRotation {
430
517
  if (generation <= sourceGeneration) throw new Error("rotation generation must exceed its source");
431
518
  const owner = parseOwner(raw.owner, runId, nodeId);
432
519
  if (owner.attemptId !== attemptId) throw new Error("rotation owner does not match its attempt");
520
+ const tierIdentity = parseCallTierIdentity(raw, "rotation");
433
521
  return {
434
522
  nodeId,
435
523
  sourceAttemptId,
@@ -438,6 +526,7 @@ function parseRotation(raw: unknown, runId: string): RecoveryRotation {
438
526
  attemptId,
439
527
  generation,
440
528
  owner,
529
+ ...tierIdentity,
441
530
  ...(raw.supersededAgentId === undefined
442
531
  ? {}
443
532
  : { supersededAgentId: boundedString(raw.supersededAgentId, "rotation.supersededAgentId", JOURNAL_ID_LIMIT) }),
@@ -467,7 +556,9 @@ function parseRunRecovery(raw: Record<string, unknown>, run: ScriptRun, timestam
467
556
  raw.terminalResults === undefined
468
557
  ? undefined
469
558
  : (() => {
470
- if (!Array.isArray(raw.terminalResults)) throw new Error("run recovery terminalResults is invalid");
559
+ if (!Array.isArray(raw.terminalResults) || raw.terminalResults.length > JOURNAL_RECOVERY_NODE_LIMIT) {
560
+ throw new Error("run recovery terminalResults are invalid");
561
+ }
471
562
  return raw.terminalResults.map((terminal) => parseTerminalResult(terminal, run.runId));
472
563
  })();
473
564
  const recoveryId = boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT);
@@ -532,7 +623,6 @@ function parseAttemptRecovery(raw: Record<string, unknown>, run: ScriptRun, time
532
623
  nodeId: rotation.nodeId,
533
624
  branchGeneration: raw.branchGeneration,
534
625
  rotation,
535
- ...(raw.legacyMigration === true ? { legacyMigration: true } : {}),
536
626
  timestamp,
537
627
  };
538
628
  }
@@ -544,56 +634,38 @@ export function recoverySemanticFingerprint(event: RunRecoveryEvent | TerminalRe
544
634
  }
545
635
 
546
636
  function parseDuplicateRunRecovery(raw: Record<string, unknown>, run: ScriptRun): RunRecoveryEvent {
547
- if (raw.status !== "interrupted") throw new Error("run recovery status is invalid");
548
- if (!Array.isArray(raw.rotations) || raw.rotations.length > JOURNAL_RECOVERY_NODE_LIMIT) {
549
- throw new Error("run recovery rotations are invalid");
550
- }
551
- const rotations = raw.rotations.map((rotation) => parseRotation(rotation, run.runId));
552
- const terminalResults =
553
- raw.terminalResults === undefined
554
- ? undefined
555
- : (() => {
556
- if (!Array.isArray(raw.terminalResults)) throw new Error("run recovery terminalResults is invalid");
557
- return raw.terminalResults.map((terminal) => parseTerminalResult(terminal, run.runId));
558
- })();
559
- return {
560
- kind: "run_recovery",
561
- schemaVersion: JOURNAL_SCHEMA_VERSION,
562
- runId: run.runId,
563
- status: "interrupted",
564
- branchGeneration: run.updatedAt, // not meaningful for duplicates; fingerprint covers content
565
- rotations,
566
- ...(terminalResults === undefined || terminalResults.length === 0 ? {} : { terminalResults }),
567
- recoveryId: boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT),
568
- timestamp: run.updatedAt,
569
- };
637
+ // Duplicate detection must fingerprint the same semantic event as the normal
638
+ // parser. In particular, preserve branchGeneration and timestamp; replacing
639
+ // either with current run state turns an idempotent retry into a conflict.
640
+ return parseRunRecovery(raw, run, validTimestamp(raw.timestamp, "timestamp"));
570
641
  }
571
642
 
572
643
  function parseDuplicateTerminalRecovery(raw: Record<string, unknown>, run: ScriptRun): TerminalRecoveryEvent {
573
- if (raw.status !== "failed" && raw.status !== "stopped") throw new Error("terminal recovery status is invalid");
574
- if (!Array.isArray(raw.terminalResults) || raw.terminalResults.length > JOURNAL_RECOVERY_NODE_LIMIT) {
575
- throw new Error("terminal recovery terminalResults are invalid");
644
+ // Preserve every semantic field, including blocked nodes, error, branch
645
+ // generation, and timestamp, so an at-least-once append is truly idempotent.
646
+ return parseTerminalRecovery(raw, run, validTimestamp(raw.timestamp, "timestamp"));
647
+ }
648
+
649
+ function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean {
650
+ if (raw.frozenArgsPresent === undefined) throw new Error("frozen args presence marker is required");
651
+ if (typeof raw.frozenArgsPresent !== "boolean") throw new Error("frozen args presence marker must be boolean");
652
+ const hasArgs = Object.hasOwn(raw, "args");
653
+ if (raw.frozenArgsPresent && (!hasArgs || raw.args === undefined)) {
654
+ throw new Error("frozen args presence marker requires workflow args");
576
655
  }
577
- const terminalResults = raw.terminalResults.map((terminal) => parseTerminalResult(terminal, run.runId));
578
- return {
579
- kind: "terminal_recovery",
580
- schemaVersion: JOURNAL_SCHEMA_VERSION,
581
- runId: run.runId,
582
- status: raw.status,
583
- terminalIntent: raw.terminalIntent === "failure" ? "failure" : "stop",
584
- branchGeneration: run.updatedAt,
585
- terminalResults,
586
- blockedNodeIds: [],
587
- recoveryId: boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT),
588
- timestamp: run.updatedAt,
589
- };
656
+ if (!raw.frozenArgsPresent && hasArgs) {
657
+ throw new Error("frozen args presence marker conflicts with workflow args");
658
+ }
659
+ return raw.frozenArgsPresent;
590
660
  }
591
661
 
592
662
  function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
593
663
  if (!isRecord(raw)) throw new Error("journal event is not an object");
594
664
  if (typeof raw.kind !== "string" || !JOURNAL_KINDS.has(raw.kind)) throw new Error("journal event kind is unknown");
595
- if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION && raw.schemaVersion !== JOURNAL_LEGACY_SCHEMA_VERSION) {
596
- throw new Error("journal schema version is unsupported");
665
+ if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) {
666
+ throw new Error(
667
+ `journal schema version ${String(raw.schemaVersion)} is unsupported; expected ${JOURNAL_SCHEMA_VERSION}`,
668
+ );
597
669
  }
598
670
  const runId = boundedString(raw.runId, "runId", JOURNAL_ID_LIMIT);
599
671
  const timestamp = validTimestamp(raw.timestamp, "timestamp");
@@ -603,46 +675,74 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
603
675
  const script = boundedString(raw.script, "script", JOURNAL_SCRIPT_LIMIT);
604
676
  const scriptHash = boundedString(raw.scriptHash, "scriptHash", 128);
605
677
  const meta = parseMeta(raw.meta);
606
- const toolset =
607
- raw.toolset === undefined
608
- ? undefined
609
- : typeof raw.toolset === "string" && raw.toolset.length > 0 && raw.toolset.length <= 64
610
- ? raw.toolset
611
- : undefined;
612
- const frozenMaxAgents =
613
- typeof raw.frozenMaxAgents === "number" && Number.isInteger(raw.frozenMaxAgents) && raw.frozenMaxAgents >= 1
614
- ? raw.frozenMaxAgents
615
- : undefined;
616
- const frozenConcurrency =
617
- typeof raw.frozenConcurrency === "number" && Number.isInteger(raw.frozenConcurrency) && raw.frozenConcurrency >= 1
618
- ? raw.frozenConcurrency
619
- : undefined;
620
- const frozenAgentRetries =
621
- typeof raw.frozenAgentRetries === "number" &&
622
- Number.isInteger(raw.frozenAgentRetries) &&
623
- raw.frozenAgentRetries >= 0
624
- ? raw.frozenAgentRetries
625
- : undefined;
626
- const frozenTokenBudget =
627
- raw.frozenTokenBudget === null
628
- ? null
629
- : typeof raw.frozenTokenBudget === "number" &&
630
- Number.isFinite(raw.frozenTokenBudget) &&
631
- raw.frozenTokenBudget >= 1
632
- ? raw.frozenTokenBudget
633
- : undefined;
634
- const frozenAgentTimeoutMs =
635
- raw.frozenAgentTimeoutMs === null
636
- ? null
637
- : typeof raw.frozenAgentTimeoutMs === "number" &&
638
- Number.isFinite(raw.frozenAgentTimeoutMs) &&
639
- raw.frozenAgentTimeoutMs >= 1
640
- ? raw.frozenAgentTimeoutMs
641
- : undefined;
642
- const frozenExcludeTools =
643
- Array.isArray(raw.frozenExcludeTools) && raw.frozenExcludeTools.every((v) => typeof v === "string")
644
- ? (raw.frozenExcludeTools as string[])
645
- : undefined;
678
+ const frozenArgsPresent = parseFrozenArgsPresence(raw);
679
+ const args = raw.args === undefined ? undefined : parsePersistedValue(raw.args, "workflow args");
680
+ const toolset = Object.hasOwn(raw, "toolset") ? boundedString(raw.toolset, "toolset", 64) : undefined;
681
+ const shippedScript = (() => {
682
+ if (!Object.hasOwn(raw, "shippedScript")) return undefined;
683
+ if (typeof raw.shippedScript !== "boolean") throw new Error("shippedScript is invalid");
684
+ return raw.shippedScript;
685
+ })();
686
+ const frozenMaxAgents = (() => {
687
+ if (!Object.hasOwn(raw, "frozenMaxAgents")) return undefined;
688
+ if (typeof raw.frozenMaxAgents !== "number" || !Number.isFinite(raw.frozenMaxAgents) || raw.frozenMaxAgents < 1) {
689
+ throw new Error("frozenMaxAgents is invalid");
690
+ }
691
+ return normalizeMaxAgents(raw.frozenMaxAgents);
692
+ })();
693
+ const frozenConcurrency = (() => {
694
+ if (!Object.hasOwn(raw, "frozenConcurrency")) return undefined;
695
+ if (
696
+ typeof raw.frozenConcurrency !== "number" ||
697
+ !Number.isFinite(raw.frozenConcurrency) ||
698
+ raw.frozenConcurrency < 1
699
+ ) {
700
+ throw new Error("frozenConcurrency is invalid");
701
+ }
702
+ return normalizeConcurrency(raw.frozenConcurrency);
703
+ })();
704
+ const frozenAgentRetries = (() => {
705
+ if (!Object.hasOwn(raw, "frozenAgentRetries")) return undefined;
706
+ if (
707
+ typeof raw.frozenAgentRetries !== "number" ||
708
+ !Number.isFinite(raw.frozenAgentRetries) ||
709
+ raw.frozenAgentRetries < 0
710
+ ) {
711
+ throw new Error("frozenAgentRetries is invalid");
712
+ }
713
+ return normalizeAgentRetries(raw.frozenAgentRetries);
714
+ })();
715
+ const frozenTokenBudget = (() => {
716
+ if (!Object.hasOwn(raw, "frozenTokenBudget")) return undefined;
717
+ if (raw.frozenTokenBudget === null) return null;
718
+ if (
719
+ typeof raw.frozenTokenBudget !== "number" ||
720
+ !Number.isFinite(raw.frozenTokenBudget) ||
721
+ raw.frozenTokenBudget < 1
722
+ ) {
723
+ throw new Error("frozenTokenBudget is invalid");
724
+ }
725
+ return normalizeTokenBudget(raw.frozenTokenBudget);
726
+ })();
727
+ const frozenAgentTimeoutMs = (() => {
728
+ if (!Object.hasOwn(raw, "frozenAgentTimeoutMs")) return undefined;
729
+ if (raw.frozenAgentTimeoutMs === null) return null;
730
+ if (
731
+ typeof raw.frozenAgentTimeoutMs !== "number" ||
732
+ !Number.isFinite(raw.frozenAgentTimeoutMs) ||
733
+ raw.frozenAgentTimeoutMs < 1
734
+ ) {
735
+ throw new Error("frozenAgentTimeoutMs is invalid");
736
+ }
737
+ return normalizeAgentTimeout(raw.frozenAgentTimeoutMs);
738
+ })();
739
+ const frozenExcludeTools = (() => {
740
+ if (!Object.hasOwn(raw, "frozenExcludeTools")) return undefined;
741
+ if (!Array.isArray(raw.frozenExcludeTools) || !raw.frozenExcludeTools.every((v) => typeof v === "string")) {
742
+ throw new Error("frozenExcludeTools is invalid");
743
+ }
744
+ return [...raw.frozenExcludeTools] as string[];
745
+ })();
646
746
  return {
647
747
  kind: "run_created",
648
748
  schemaVersion: raw.schemaVersion as JournalSchemaVersion,
@@ -650,6 +750,9 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
650
750
  script,
651
751
  scriptHash,
652
752
  meta,
753
+ ...(args === undefined ? {} : { args }),
754
+ frozenArgsPresent,
755
+ ...(shippedScript === undefined ? {} : { shippedScript }),
653
756
  ...(toolset ? { toolset } : {}),
654
757
  ...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
655
758
  ...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
@@ -695,12 +798,21 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
695
798
  if (raw.terminalIntent !== undefined && raw.terminalIntent !== "stop" && raw.terminalIntent !== "failure") {
696
799
  throw new Error("workflow terminal intent is invalid");
697
800
  }
801
+ let finalResult: unknown;
802
+ if (raw.finalResult !== undefined) {
803
+ try {
804
+ finalResult = structuredClone(raw.finalResult);
805
+ } catch {
806
+ throw new Error("workflow finalResult is not structured-cloneable");
807
+ }
808
+ }
698
809
  return {
699
810
  kind: "workflow_transition",
700
811
  schemaVersion: JOURNAL_SCHEMA_VERSION,
701
812
  runId,
702
813
  status: raw.status as WorkflowStatus,
703
814
  timestamp,
815
+ ...(finalResult === undefined ? {} : { finalResult }),
704
816
  ...(raw.error === undefined ? {} : { error: boundedString(raw.error, "error", JOURNAL_ERROR_LIMIT) }),
705
817
  ...(raw.terminalIntent === undefined ? {} : { terminalIntent: raw.terminalIntent }),
706
818
  };
@@ -714,6 +826,63 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
714
826
  }
715
827
 
716
828
  const nodeId = parseCallIndex(raw.nodeId, "nodeId");
829
+ if (raw.kind === "workflow_result") {
830
+ if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("workflow result schema version is unsupported");
831
+ if (raw.owner !== undefined || raw.attemptId !== undefined)
832
+ throw new Error("workflow result cannot carry a managed attempt");
833
+ const result = mergeCallResultTierIdentity(
834
+ priorCallTierIdentity(run, nodeId),
835
+ parseResult(raw.result, "workflow result"),
836
+ );
837
+ if (result.status !== "completed") throw new Error("workflow result must be completed");
838
+ if (result.attemptId !== undefined) throw new Error("workflow result payload cannot carry a managed attempt");
839
+ const generation = raw.generation;
840
+ if (typeof generation !== "number" || !Number.isInteger(generation) || generation < 1 || generation > 1_000_000) {
841
+ throw new Error("workflow result generation is invalid");
842
+ }
843
+ const latestGeneration = run.workflowResultGenerations[nodeId] ?? 0;
844
+ if (generation < latestGeneration) {
845
+ throw new StaleJournalGenerationError("nested workflow result generation is stale");
846
+ }
847
+ if (
848
+ generation === latestGeneration &&
849
+ run.callResults[nodeId] &&
850
+ JSON.stringify(run.callResults[nodeId]) !== JSON.stringify(result)
851
+ ) {
852
+ throw new Error("contradictory nested workflow result");
853
+ }
854
+ const agentCount =
855
+ raw.agentCount === undefined
856
+ ? undefined
857
+ : typeof raw.agentCount === "number" &&
858
+ Number.isInteger(raw.agentCount) &&
859
+ raw.agentCount >= 0 &&
860
+ raw.agentCount <= JOURNAL_CALL_LIMIT
861
+ ? raw.agentCount
862
+ : (() => {
863
+ throw new Error("workflow result agentCount is invalid");
864
+ })();
865
+ const callHash = boundedString(raw.callHash, "workflow result callHash", 128);
866
+ const storeDelta =
867
+ raw.storeDelta === undefined
868
+ ? undefined
869
+ : (() => {
870
+ if (!isRecord(raw.storeDelta)) throw new Error("workflow result storeDelta is invalid");
871
+ return raw.storeDelta as Record<string, unknown>;
872
+ })();
873
+ return {
874
+ kind: "workflow_result",
875
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
876
+ runId,
877
+ nodeId,
878
+ result,
879
+ generation,
880
+ ...(agentCount === undefined ? {} : { agentCount }),
881
+ callHash,
882
+ ...(storeDelta === undefined ? {} : { storeDelta }),
883
+ timestamp,
884
+ };
885
+ }
717
886
  const owner = raw.owner === undefined ? undefined : parseOwner(raw.owner, runId, nodeId);
718
887
  const rawAttemptId =
719
888
  raw.attemptId === undefined ? undefined : boundedString(raw.attemptId, "attemptId", JOURNAL_ID_LIMIT);
@@ -740,6 +909,7 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
740
909
  if (attemptId !== expectedAttemptId || owner?.attemptId !== attemptId) {
741
910
  throw new Error("call attempt does not identify its next generation");
742
911
  }
912
+ const tierIdentity = parseCallTierIdentity(raw, "call attempt");
743
913
  return {
744
914
  kind: "call_attempt",
745
915
  schemaVersion: JOURNAL_SCHEMA_VERSION,
@@ -747,6 +917,7 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
747
917
  nodeId,
748
918
  attemptId,
749
919
  generation,
920
+ ...tierIdentity,
750
921
  timestamp,
751
922
  ...(owner ? { owner } : {}),
752
923
  };
@@ -755,12 +926,14 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
755
926
  if (raw.kind === "call_transition") {
756
927
  if (!CALL_STATUS_VALUES.has(raw.status as CallStatus)) throw new Error("call status is unknown");
757
928
  const agentId = raw.agentId === undefined ? undefined : boundedString(raw.agentId, "agentId", JOURNAL_ID_LIMIT);
929
+ const tierIdentity = parseCallTierIdentity(raw, "call transition");
758
930
  return {
759
931
  kind: "call_transition",
760
932
  schemaVersion: JOURNAL_SCHEMA_VERSION,
761
933
  runId,
762
934
  nodeId,
763
935
  status: raw.status as CallStatus,
936
+ ...tierIdentity,
764
937
  timestamp,
765
938
  ...(agentId ? { agentId } : {}),
766
939
  ...(attemptId ? { attemptId } : {}),
@@ -775,12 +948,14 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
775
948
  if (result.attemptId && attemptId && result.attemptId !== attemptId) {
776
949
  throw new StaleJournalGenerationError("call result payload references a superseded attempt");
777
950
  }
951
+ const normalizedResult = result.attemptId === undefined && attemptId ? { ...result, attemptId } : result;
952
+ const resultWithTierIdentity = mergeCallResultTierIdentity(priorCallTierIdentity(run, nodeId), normalizedResult);
778
953
  return {
779
954
  kind: "call_result",
780
955
  schemaVersion: JOURNAL_SCHEMA_VERSION,
781
956
  runId,
782
957
  nodeId,
783
- result: result.attemptId === undefined && attemptId ? { ...result, attemptId } : result,
958
+ result: resultWithTierIdentity,
784
959
  ...(raw.callHash === undefined ? {} : { callHash: boundedString(raw.callHash, "callHash", 128) }),
785
960
  ...(raw.storeDelta === undefined
786
961
  ? {}
@@ -814,6 +989,8 @@ export function replayJournal(
814
989
  const runs = new Map<string, ScriptRun>();
815
990
  const quarantined = new Set<string>();
816
991
  const removed = new Set<string>();
992
+ /** Parsed creation facts are retained separately from mutable run state for duplicate checks. */
993
+ const creationFingerprints = new Map<string, string>();
817
994
  /** Recovery identities are scoped to one replayed run and bounded explicitly. */
818
995
  const seenRecoveries = new Map<string, Map<string, string>>();
819
996
  let reported = false;
@@ -828,16 +1005,28 @@ export function replayJournal(
828
1005
  const rawRunId = isRecord(raw) && typeof raw.runId === "string" ? raw.runId : undefined;
829
1006
  if (rawRunId && quarantined.has(rawRunId)) continue;
830
1007
  if (rawRunId && removed.has(rawRunId)) continue;
1008
+ // Set inside the guarded region, delivered outside it. An observer that
1009
+ // throws is reporting that the accepted facts contradict each other, which
1010
+ // is exactly the condition the caller needs to see — running it inside the
1011
+ // `try` would quarantine the run and swallow the diagnostic, turning a real
1012
+ // conflict into a silently missing cache entry. `finally` also delivers on
1013
+ // the `continue` paths, which a trailing statement would skip.
1014
+ let accepted: JournalEvent | undefined;
831
1015
  try {
832
- if (isRecord(raw) && raw.kind === "run_created" && rawRunId && runs.has(rawRunId)) {
833
- const existing = runs.get(rawRunId);
834
- if (
835
- existing &&
836
- existing.script === raw.script &&
837
- existing.scriptHash === raw.scriptHash &&
838
- JSON.stringify(existing.meta) === JSON.stringify(raw.meta)
839
- )
840
- continue;
1016
+ if (
1017
+ isRecord(raw) &&
1018
+ raw.schemaVersion === JOURNAL_SCHEMA_VERSION &&
1019
+ raw.kind === "run_created" &&
1020
+ rawRunId &&
1021
+ runs.has(rawRunId)
1022
+ ) {
1023
+ // Parse against an empty run map so every creation field participates in
1024
+ // the comparison, including frozen limits and attempt seeds. Comparing
1025
+ // against mutable run state would make a harmless retry conflict after
1026
+ // later call/recovery events have changed that state.
1027
+ const candidate = parseEvent(raw, new Map());
1028
+ const candidateFingerprint = JSON.stringify(candidate);
1029
+ if (creationFingerprints.get(rawRunId) === candidateFingerprint) continue;
841
1030
  throw new Error("conflicting duplicate workflow run_created event");
842
1031
  }
843
1032
  if (
@@ -850,7 +1039,12 @@ export function replayJournal(
850
1039
  throw new Error(`recoveryId ${raw.recoveryId} is already owned by workflow run ${seenRunId}`);
851
1040
  }
852
1041
  }
853
- if (isRecord(raw) && (raw.kind === "run_recovery" || raw.kind === "terminal_recovery") && rawRunId) {
1042
+ if (
1043
+ isRecord(raw) &&
1044
+ raw.schemaVersion === JOURNAL_SCHEMA_VERSION &&
1045
+ (raw.kind === "run_recovery" || raw.kind === "terminal_recovery") &&
1046
+ rawRunId
1047
+ ) {
854
1048
  const run = runs.get(rawRunId);
855
1049
  const seen = seenRecoveries.get(rawRunId);
856
1050
  if (run && seen) {
@@ -873,7 +1067,9 @@ export function replayJournal(
873
1067
  if (quarantined.has(event.runId)) continue;
874
1068
  if (event.kind === "run_created") {
875
1069
  applyJournalEvent(runs, event);
1070
+ creationFingerprints.set(event.runId, JSON.stringify(event));
876
1071
  seenRecoveries.set(event.runId, new Map());
1072
+ accepted = event;
877
1073
  continue;
878
1074
  }
879
1075
  if (event.kind === "run_recovery" || event.kind === "terminal_recovery") {
@@ -892,9 +1088,11 @@ export function replayJournal(
892
1088
  applyJournalEvent(runs, event);
893
1089
  seen.set(event.recoveryId, recoverySemanticFingerprint(event));
894
1090
  seenRecoveries.set(event.runId, seen);
1091
+ accepted = event;
895
1092
  continue;
896
1093
  }
897
1094
  applyJournalEvent(runs, event);
1095
+ accepted = event;
898
1096
  if (event.kind === "run_removed") {
899
1097
  removed.add(event.runId);
900
1098
  }
@@ -903,9 +1101,12 @@ export function replayJournal(
903
1101
  if (rawRunId) {
904
1102
  quarantined.add(rawRunId);
905
1103
  runs.delete(rawRunId);
1104
+ creationFingerprints.delete(rawRunId);
906
1105
  seenRecoveries.delete(rawRunId);
907
1106
  }
908
1107
  report(error instanceof Error ? error.message : String(error));
1108
+ } finally {
1109
+ if (accepted) options.onAccepted?.(accepted);
909
1110
  }
910
1111
  }
911
1112
  for (const [runId, run] of runs) {
@@ -914,6 +1115,7 @@ export function replayJournal(
914
1115
  } catch (error: unknown) {
915
1116
  runs.delete(runId);
916
1117
  quarantined.add(runId);
1118
+ creationFingerprints.delete(runId);
917
1119
  seenRecoveries.delete(runId);
918
1120
  report(`${runId}: ${error instanceof Error ? error.message : String(error)}`);
919
1121
  }
@@ -927,15 +1129,19 @@ function applyRecoveryRotation(run: ScriptRun, rotation: RecoveryRotation): void
927
1129
  run.attemptIds[rotation.nodeId] = rotation.attemptId;
928
1130
  delete run.agentIds[rotation.nodeId];
929
1131
  delete run.callResults[rotation.nodeId];
1132
+ const rotationTier = rotation.tier ?? run.callTiers[rotation.nodeId]?.tier;
1133
+ if (rotationTier !== undefined) run.callTiers[rotation.nodeId] = { tier: rotationTier };
930
1134
  run.compactions[rotation.nodeId] = 0;
931
1135
  run.callStatus[rotation.nodeId] = "running";
932
1136
  }
933
1137
 
934
1138
  function applyRecoveryTerminalResult(run: ScriptRun, terminal: RecoveryTerminalResult): void {
935
1139
  run.compactions[terminal.nodeId] = Math.max(run.compactions[terminal.nodeId] ?? 0, terminal.result.compactionCount);
936
- run.callResults[terminal.nodeId] = terminal.result;
937
- run.callStatus[terminal.nodeId] = terminal.result.status;
938
- if (terminal.result.agentId) run.agentIds[terminal.nodeId] = terminal.result.agentId;
1140
+ const result = mergeCallResultTierIdentity(priorCallTierIdentity(run, terminal.nodeId), terminal.result);
1141
+ run.callResults[terminal.nodeId] = result;
1142
+ run.callTiers[terminal.nodeId] = result.tier === undefined ? {} : { tier: result.tier };
1143
+ run.callStatus[terminal.nodeId] = result.status;
1144
+ if (result.agentId) run.agentIds[terminal.nodeId] = result.agentId;
939
1145
  }
940
1146
 
941
1147
  export function applyRecoveryTerminalResults(run: ScriptRun, terminalResults: readonly RecoveryTerminalResult[]): void {
@@ -983,7 +1189,10 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
983
1189
  script: event.script,
984
1190
  scriptHash: event.scriptHash,
985
1191
  meta: event.meta,
1192
+ ...(event.args === undefined ? {} : { args: structuredClone(event.args) }),
1193
+ frozenArgsPresent: event.frozenArgsPresent,
986
1194
  revision: 0,
1195
+ ...(event.shippedScript === undefined ? {} : { shippedScript: event.shippedScript }),
987
1196
  ...(event.toolset ? { toolset: event.toolset } : {}),
988
1197
  ...(event.frozenMaxAgents !== undefined ? { frozenMaxAgents: event.frozenMaxAgents } : {}),
989
1198
  ...(event.frozenConcurrency !== undefined ? { frozenConcurrency: event.frozenConcurrency } : {}),
@@ -998,6 +1207,8 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
998
1207
  attemptIds,
999
1208
  attemptTracking: event.attemptIds !== undefined,
1000
1209
  callResults: {},
1210
+ callTiers: {},
1211
+ workflowResultGenerations: {},
1001
1212
  compactions: {},
1002
1213
  startedAt: event.timestamp,
1003
1214
  updatedAt: event.timestamp,
@@ -1022,6 +1233,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1022
1233
  case "workflow_transition":
1023
1234
  run.status = event.status;
1024
1235
  if (event.error !== undefined) run.error = event.error;
1236
+ if (event.finalResult !== undefined) run.finalResult = structuredClone(event.finalResult);
1025
1237
  if (event.terminalIntent !== undefined) run.terminalIntent = event.terminalIntent;
1026
1238
  break;
1027
1239
  case "run_recovery":
@@ -1037,6 +1249,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1037
1249
  run.attemptIds[event.nodeId] = event.attemptId;
1038
1250
  delete run.agentIds[event.nodeId];
1039
1251
  delete run.callResults[event.nodeId];
1252
+ if (event.tier !== undefined) run.callTiers[event.nodeId] = { tier: event.tier };
1040
1253
  run.callStatus[event.nodeId] = "running";
1041
1254
  run.compactions[event.nodeId] = 0;
1042
1255
  break;
@@ -1044,28 +1257,52 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1044
1257
  if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
1045
1258
  throw new Error("call transition references a superseded attempt");
1046
1259
  }
1047
- run.callStatus[event.nodeId] = event.status;
1260
+ if (event.tier !== undefined) run.callTiers[event.nodeId] = { tier: event.tier };
1261
+ // Identity-only transitions use status "running". A delayed retry must
1262
+ // not regress a terminal call result that was already replayed.
1263
+ if (
1264
+ event.status !== "running" ||
1265
+ run.callStatus[event.nodeId] === undefined ||
1266
+ run.callStatus[event.nodeId] === "running"
1267
+ ) {
1268
+ run.callStatus[event.nodeId] = event.status;
1269
+ }
1048
1270
  if (event.agentId) run.agentIds[event.nodeId] = event.agentId;
1049
1271
  break;
1050
- case "call_result":
1272
+ case "workflow_result": {
1273
+ run.workflowResultGenerations[event.nodeId] = event.generation;
1274
+ const result = mergeCallResultTierIdentity(priorCallTierIdentity(run, event.nodeId), event.result);
1275
+ run.callResults[event.nodeId] = result;
1276
+ run.callTiers[event.nodeId] = result.tier === undefined ? {} : { tier: result.tier };
1277
+ run.callStatus[event.nodeId] = result.status;
1278
+ run.compactions[event.nodeId] = result.compactionCount;
1279
+ break;
1280
+ }
1281
+ case "call_result": {
1051
1282
  if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
1052
1283
  throw new Error("call result references a superseded attempt");
1053
1284
  }
1054
- if (
1055
- run.callResults[event.nodeId] &&
1056
- JSON.stringify(run.callResults[event.nodeId]) !== JSON.stringify(event.result)
1057
- ) {
1285
+ const result = mergeCallResultTierIdentity(priorCallTierIdentity(run, event.nodeId), event.result);
1286
+ if (run.callResults[event.nodeId] && JSON.stringify(run.callResults[event.nodeId]) !== JSON.stringify(result)) {
1058
1287
  throw new Error("contradictory terminal call result");
1059
1288
  }
1060
- run.callResults[event.nodeId] = event.result;
1061
- run.callStatus[event.nodeId] = event.result.status;
1062
- run.compactions[event.nodeId] = Math.max(run.compactions[event.nodeId] ?? 0, event.result.compactionCount);
1063
- if (event.result.agentId) run.agentIds[event.nodeId] = event.result.agentId;
1289
+ run.callResults[event.nodeId] = result;
1290
+ run.callTiers[event.nodeId] = result.tier === undefined ? {} : { tier: result.tier };
1291
+ run.callStatus[event.nodeId] = result.status;
1292
+ run.compactions[event.nodeId] = Math.max(run.compactions[event.nodeId] ?? 0, result.compactionCount);
1293
+ if (result.agentId) run.agentIds[event.nodeId] = result.agentId;
1064
1294
  break;
1295
+ }
1065
1296
  }
1066
1297
  }
1067
1298
 
1068
1299
  function validateAggregate(run: ScriptRun): void {
1300
+ if (run.frozenArgsPresent === true && run.args === undefined) {
1301
+ throw new Error("frozen args presence marker requires workflow args");
1302
+ }
1303
+ if (run.frozenArgsPresent === false && run.args !== undefined) {
1304
+ throw new Error("frozen args presence marker conflicts with workflow args");
1305
+ }
1069
1306
  for (const nodeId of Object.keys(run.attemptIds)) {
1070
1307
  if (!Number.isInteger(run.attempts[nodeId]) || run.attempts[nodeId] < 1) {
1071
1308
  throw new Error(`missing attempt generation for ${nodeId}`);
@@ -1074,8 +1311,11 @@ function validateAggregate(run: ScriptRun): void {
1074
1311
  }
1075
1312
  if (isTerminalWorkflowStatus(run.status)) {
1076
1313
  const statuses = Object.values(run.callStatus);
1077
- if (run.status === "completed" && statuses.some((status) => status !== "completed")) {
1078
- throw new Error("completed workflow has non-completed calls");
1314
+ if (
1315
+ run.status === "completed" &&
1316
+ statuses.some((status) => status !== "completed" && status !== "failed" && status !== "stopped")
1317
+ ) {
1318
+ throw new Error("completed workflow has active calls");
1079
1319
  }
1080
1320
  if (
1081
1321
  run.status === "stopped" &&
@@ -1126,44 +1366,82 @@ export function snapshotRun(run: ScriptRun): ScriptRun {
1126
1366
  *
1127
1367
  * The runtime keys its cache by `${runId}:${callIndex}` (the same namespacing
1128
1368
  * the store deltas use), so a nested workflow() call with its own runId can
1129
- * never replay the parent's entry. Every `call_result` event that carries a
1130
- * `callHash` is a replayable agent()/checkpoint() fact; entries without a hash
1131
- * (legacy or malformed) are skipped rather than quarantined — a resume must
1132
- * tolerate a foreign or partial journal and simply re-run the misses live.
1369
+ * never replay the parent's entry. Every `call_result` or `workflow_result`
1370
+ * event that carries a `callHash` is a replayable fact. Entries from another
1371
+ * schema or without a hash are not replayable; they are ignored here even when
1372
+ * `replayJournal` retains them as non-replayable lifecycle history.
1133
1373
  */
1134
- export function buildResumeJournal(
1135
- entries: readonly SessionEntryLike[],
1136
- ): Map<string, { index: number; runId?: string; hash: string; result: unknown; storeDelta?: Record<string, unknown> }> {
1137
- const journal = new Map<
1138
- string,
1139
- { index: number; runId?: string; hash: string; result: unknown; storeDelta?: Record<string, unknown> }
1140
- >();
1141
- for (const entry of entries) {
1142
- if (entry.type !== "custom" || entry.customType !== JOURNAL_ENTRY_TYPE) continue;
1143
- const raw = entry.data;
1144
- if (!isRecord(raw) || raw.kind !== "call_result" || typeof raw.callHash !== "string") continue;
1145
- if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) continue;
1146
- const runId = boundedString(raw.runId, "runId", JOURNAL_ID_LIMIT);
1147
- let nodeId: string;
1148
- try {
1149
- nodeId = parseCallIndex(raw.nodeId, "nodeId");
1150
- } catch {
1151
- continue;
1152
- }
1153
- const result = isRecord(raw.result) ? raw.result : undefined;
1154
- // Failed/stopped lifecycle facts are diagnostic history, not successful
1155
- // replay values. Replaying them as `undefined` would silently skip a call
1156
- // and bypass the runtime's retry/error policy.
1157
- if (result?.status !== "completed" || !Object.hasOwn(result, "result")) continue;
1158
- journal.set(`${runId}:${nodeId}`, {
1159
- index: Number(nodeId),
1160
- runId,
1161
- hash: raw.callHash,
1162
- result: result.result,
1163
- ...(isRecord(raw.storeDelta) && Object.keys(raw.storeDelta).length > 0
1164
- ? { storeDelta: raw.storeDelta as Record<string, unknown> }
1165
- : {}),
1166
- });
1374
+ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
1375
+ string,
1376
+ {
1377
+ index: number;
1378
+ runId?: string;
1379
+ hash: string;
1380
+ result: unknown;
1381
+ tier?: string;
1382
+ storeDelta?: Record<string, unknown>;
1383
+ generation?: number;
1384
+ agentCount?: number;
1385
+ }
1386
+ > {
1387
+ type ResumeEntry = {
1388
+ index: number;
1389
+ runId?: string;
1390
+ hash: string;
1391
+ result: unknown;
1392
+ tier?: string;
1393
+ storeDelta?: Record<string, unknown>;
1394
+ generation?: number;
1395
+ agentCount?: number;
1396
+ };
1397
+ const journal = new Map<string, ResumeEntry>();
1398
+ const journalRunIds = new Map<string, string>();
1399
+
1400
+ // Reuse the authoritative replay validator so stale call results from an
1401
+ // older attempt, terminal-after-lifecycle facts, and quarantined runs never
1402
+ // enter the cache merely because they appear later in the session branch.
1403
+ const replayedRuns = replayJournal(entries, {
1404
+ onAccepted: (event) => {
1405
+ if ((event.kind !== "call_result" && event.kind !== "workflow_result") || event.callHash === undefined) return;
1406
+ if (event.result.status !== "completed" || !Object.hasOwn(event.result, "result")) return;
1407
+
1408
+ const candidate: ResumeEntry = {
1409
+ index: Number(event.nodeId),
1410
+ runId: event.runId,
1411
+ hash: event.callHash,
1412
+ result: event.result.result,
1413
+ ...(event.result.tier === undefined ? {} : { tier: event.result.tier }),
1414
+ ...(event.kind === "workflow_result" ? { generation: event.generation } : {}),
1415
+ ...(event.kind === "workflow_result" && event.agentCount !== undefined ? { agentCount: event.agentCount } : {}),
1416
+ ...(event.storeDelta !== undefined && Object.keys(event.storeDelta).length > 0
1417
+ ? { storeDelta: event.storeDelta }
1418
+ : {}),
1419
+ };
1420
+ const key = `${event.runId}:${event.nodeId}`;
1421
+ const existing = journal.get(key);
1422
+ if (candidate.generation !== undefined && existing?.generation !== undefined) {
1423
+ if (candidate.generation < existing.generation) return;
1424
+ // Same generation, same content is an at-least-once retry. Same
1425
+ // generation, different content is a contradiction `replayJournal` has
1426
+ // already rejected before accepting the second fact — reaching here
1427
+ // would mean the two validators disagree, so surface it rather than
1428
+ // quietly keeping whichever arrived first.
1429
+ if (candidate.generation === existing.generation) {
1430
+ if (JSON.stringify(existing) !== JSON.stringify(candidate))
1431
+ throw new Error("contradictory nested workflow result");
1432
+ return;
1433
+ }
1434
+ }
1435
+ journal.set(key, candidate);
1436
+ journalRunIds.set(key, event.runId);
1437
+ },
1438
+ });
1439
+
1440
+ // replayJournal may quarantine a run after an accepted fact (for example,
1441
+ // when a later contradictory event invalidates the aggregate). Do not retain
1442
+ // cache entries belonging to that run.
1443
+ for (const [key, runId] of journalRunIds) {
1444
+ if (!replayedRuns.has(runId)) journal.delete(key);
1167
1445
  }
1168
1446
  return journal;
1169
1447
  }