@signalridge/pi-workflows 1.4.0 → 1.6.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,5 +1,5 @@
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
@@ -17,13 +17,14 @@
17
17
  * preserved from the declarative journal and re-keyed to call indexes, because
18
18
  * interrupted managed spawns still need generation rotation on restore.
19
19
  *
20
- * Schema v2 runs (declarative DAG journals) are quarantined rather than
21
- * replayed — the event shapes are incompatible and a script run must not
22
- * 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.
23
23
  */
24
24
 
25
25
  import { createHash } from "node:crypto";
26
26
  import type { ManagedOwner } from "@signalridge/pi-subagents-protocol";
27
+ import { isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
27
28
  import {
28
29
  MAX_CALLS_PER_WORKFLOW,
29
30
  normalizeAgentRetries,
@@ -35,16 +36,15 @@ import {
35
36
  } from "./runtime.js";
36
37
 
37
38
  export const JOURNAL_ENTRY_TYPE = "pi-workflows:journal";
38
- export const JOURNAL_SCHEMA_VERSION = 3;
39
- /** Schema v2 (declarative DAG) and v1 are quarantined on replay, not migrated. */
40
- export const JOURNAL_LEGACY_SCHEMA_VERSION = 2;
41
- 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;
42
42
  export const JOURNAL_TEXT_LIMIT = 8_000;
43
43
  export const JOURNAL_SCRIPT_LIMIT = 200_000;
44
44
  export const JOURNAL_ERROR_LIMIT = 2_000;
45
45
  export const JOURNAL_ID_LIMIT = 256;
46
46
  export const JOURNAL_TIMESTAMP_LIMIT = 8_640_000_000_000_000;
47
- export const JOURNAL_RECOVERY_ID_PREFIX = "r3-";
47
+ export const JOURNAL_RECOVERY_ID_PREFIX = "r4-";
48
48
  export const JOURNAL_RECOVERY_ID_LIMIT = JOURNAL_RECOVERY_ID_PREFIX.length + 64;
49
49
  /** Maximum number of distinct atomic recoveries retained while replaying one run. */
50
50
  export const JOURNAL_RECOVERY_SEEN_LIMIT = 4_096;
@@ -52,7 +52,7 @@ export const JOURNAL_RECOVERY_SEEN_LIMIT = 4_096;
52
52
  export const JOURNAL_CALL_LIMIT = MAX_CALLS_PER_WORKFLOW;
53
53
  /** One atomic recovery event may rotate every active call in a run. */
54
54
  export const JOURNAL_RECOVERY_NODE_LIMIT = JOURNAL_CALL_LIMIT;
55
- const RECOVERY_ID_PATTERN = /^r3-[0-9a-f]{64}$/;
55
+ const RECOVERY_ID_PATTERN = /^r4-[0-9a-f]{64}$/;
56
56
 
57
57
  export class StaleJournalGenerationError extends Error {
58
58
  constructor(message: string) {
@@ -88,7 +88,6 @@ const WORKFLOW_STATUS_VALUES = new Set<WorkflowStatus>([
88
88
  const CALL_STATUS_VALUES = new Set<CallStatus>(["running", "completed", "failed", "stopped"]);
89
89
  const JOURNAL_KINDS = new Set([
90
90
  "run_created",
91
- "run_args",
92
91
  "run_revision",
93
92
  "run_removed",
94
93
  "workflow_transition",
@@ -103,8 +102,13 @@ const JOURNAL_KINDS = new Set([
103
102
 
104
103
  export type WorkflowOwner = ManagedOwner;
105
104
 
105
+ /** The Agent tier one journaled call resolved to. */
106
+ export interface CallTierIdentity {
107
+ tier?: string;
108
+ }
109
+
106
110
  /** A completed agent()/checkpoint() call, journaled for resume replay. */
107
- export interface CallResult {
111
+ export interface CallResult extends CallTierIdentity {
108
112
  status: "completed" | "failed" | "stopped";
109
113
  agentId?: string;
110
114
  attemptId?: string;
@@ -125,8 +129,8 @@ export interface ScriptRun {
125
129
  meta: WorkflowMeta;
126
130
  /** Frozen invocation input reused by automatic and manual resume. */
127
131
  args?: unknown;
128
- /** Explicit on current runs; absent only for legacy schema-v3 run_created facts. */
129
- frozenArgsPresent?: boolean;
132
+ /** Explicit marker recording whether the current run was given args. */
133
+ frozenArgsPresent: boolean;
130
134
  /** Number of the latest edited-script revision. */
131
135
  revision?: number;
132
136
  /** Toolset marker persisted across resume (e.g. "web-research" for deep-research). */
@@ -148,6 +152,8 @@ export interface ScriptRun {
148
152
  attemptTracking?: boolean;
149
153
  /** Call index → journaled result (the resume journal source). */
150
154
  callResults: Record<string, CallResult>;
155
+ /** Call index → resolved tier identity for live and stopped calls. */
156
+ callTiers: Record<string, CallTierIdentity>;
151
157
  /** Call index → latest accepted nested workflow result generation. */
152
158
  workflowResultGenerations: Record<string, number>;
153
159
  compactions: Record<string, number>;
@@ -170,7 +176,7 @@ interface JournalBase {
170
176
  /** State observed immediately before an atomic recovery rotation. */
171
177
  export type RecoverySourceStatus = CallStatus | "running";
172
178
 
173
- export interface RecoveryRotation {
179
+ export interface RecoveryRotation extends CallTierIdentity {
174
180
  nodeId: string;
175
181
  sourceAttemptId: string;
176
182
  sourceGeneration: number;
@@ -195,8 +201,6 @@ export interface RunRecoveryEvent extends JournalBase {
195
201
  rotations: RecoveryRotation[];
196
202
  terminalResults?: RecoveryTerminalResult[];
197
203
  recoveryId: string;
198
- /** Set only when reading a schema-v2 atomic event written before recoveryId existed. */
199
- legacyMigration?: true;
200
204
  timestamp: number;
201
205
  }
202
206
 
@@ -217,8 +221,6 @@ export interface AttemptRecoveryEvent extends JournalBase {
217
221
  nodeId: string;
218
222
  branchGeneration: number;
219
223
  rotation: RecoveryRotation;
220
- /** Only used to migrate a legacy prefix ending after workflow_transition. */
221
- legacyMigration?: true;
222
224
  }
223
225
 
224
226
  export type JournalEvent =
@@ -228,8 +230,8 @@ export type JournalEvent =
228
230
  scriptHash: string;
229
231
  meta: WorkflowMeta;
230
232
  args?: unknown;
231
- /** Explicit args presence marker; absent only on legacy schema-v3 facts. */
232
- frozenArgsPresent?: boolean;
233
+ /** Explicit args presence marker for deterministic resume. */
234
+ frozenArgsPresent: boolean;
233
235
  toolset?: string;
234
236
  frozenMaxAgents?: number;
235
237
  frozenConcurrency?: number;
@@ -240,11 +242,6 @@ export type JournalEvent =
240
242
  attempts?: Record<string, number>;
241
243
  attemptIds?: Record<string, string>;
242
244
  })
243
- | (JournalBase & {
244
- /** Canonical invocation args backfilled when resuming an older schema-v3 run. */
245
- kind: "run_args";
246
- args: unknown;
247
- })
248
245
  | (JournalBase & {
249
246
  kind: "run_revision";
250
247
  revision: number;
@@ -269,6 +266,7 @@ export type JournalEvent =
269
266
  kind: "call_transition";
270
267
  nodeId: string;
271
268
  status: CallStatus;
269
+ tier?: string;
272
270
  agentId?: string;
273
271
  attemptId?: string;
274
272
  owner?: WorkflowOwner;
@@ -278,6 +276,7 @@ export type JournalEvent =
278
276
  nodeId: string;
279
277
  attemptId: string;
280
278
  generation: number;
279
+ tier?: string;
281
280
  owner?: WorkflowOwner;
282
281
  })
283
282
  | (JournalBase & {
@@ -318,6 +317,8 @@ export interface SessionEntryLike {
318
317
 
319
318
  export interface ReplayJournalOptions {
320
319
  onInvalid?: (diagnostic: string) => void;
320
+ /** Internal observer for events that passed replay validation and were applied. */
321
+ onAccepted?: (event: JournalEvent) => void;
321
322
  }
322
323
 
323
324
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -362,23 +363,33 @@ function parseMeta(raw: unknown): WorkflowMeta {
362
363
  if (!isRecord(raw)) throw new Error("meta is invalid");
363
364
  const name = boundedString(raw.name, "meta.name", 512);
364
365
  const description = boundedString(raw.description, "meta.description", 100_000);
366
+ if (Object.hasOwn(raw, "model") || Object.hasOwn(raw, "thinking")) {
367
+ throw new Error("workflow journal meta model/thinking fields are unsupported");
368
+ }
365
369
  const meta: WorkflowMeta = { name, description };
366
- if (raw.model !== undefined) meta.model = boundedString(raw.model, "meta.model", 512);
367
370
  if (raw.phases !== undefined) {
368
371
  if (!Array.isArray(raw.phases)) throw new Error("meta.phases must be an array");
369
372
  meta.phases = raw.phases.map((phase, i) => {
370
373
  if (!isRecord(phase) || typeof phase.title !== "string" || phase.title.length === 0) {
371
374
  throw new Error(`meta.phases[${i}].title is invalid`);
372
375
  }
376
+ if (Object.hasOwn(phase, "model") || Object.hasOwn(phase, "thinking")) {
377
+ throw new Error(`workflow journal phase ${i} model/thinking fields are unsupported`);
378
+ }
373
379
  const out: NonNullable<WorkflowMeta["phases"]>[number] = { title: phase.title.slice(0, 512) };
374
380
  if (phase.detail !== undefined) out.detail = String(phase.detail).slice(0, 2_000);
375
- if (phase.model !== undefined) out.model = String(phase.model).slice(0, 512);
376
381
  return out;
377
382
  });
378
383
  }
379
384
  return meta;
380
385
  }
381
386
 
387
+ function parseCallTierIdentity(raw: Record<string, unknown>, label: string): CallTierIdentity {
388
+ if (raw.tier === undefined) return {};
389
+ if (!isManagedAgentTier(raw.tier)) throw new Error(`${label}.tier is invalid`);
390
+ return { tier: raw.tier };
391
+ }
392
+
382
393
  function parseResult(raw: unknown, label: string): CallResult {
383
394
  if (!isRecord(raw)) throw new Error(`${label} is invalid`);
384
395
  const status = raw.status;
@@ -390,10 +401,12 @@ function parseResult(raw: unknown, label: string): CallResult {
390
401
  throw new Error(`${label} compactionCount is invalid`);
391
402
  }
392
403
  const updatedAt = validTimestamp(raw.updatedAt, label);
404
+ const tierIdentity = parseCallTierIdentity(raw, label);
393
405
  const result: CallResult = {
394
406
  status,
395
407
  compactionCount,
396
408
  updatedAt,
409
+ ...tierIdentity,
397
410
  ...(raw.agentId === undefined ? {} : { agentId: boundedString(raw.agentId, `${label}.agentId`, JOURNAL_ID_LIMIT) }),
398
411
  ...(raw.attemptId === undefined
399
412
  ? {}
@@ -417,6 +430,17 @@ function parseResult(raw: unknown, label: string): CallResult {
417
430
  return result;
418
431
  }
419
432
 
433
+ function mergeCallResultTierIdentity(previous: CallTierIdentity | undefined, result: CallResult): CallResult {
434
+ return result.tier === undefined && previous?.tier !== undefined ? { ...result, tier: previous.tier } : result;
435
+ }
436
+
437
+ function priorCallTierIdentity(run: ScriptRun, nodeId: string): CallTierIdentity {
438
+ return {
439
+ ...(run.callTiers[nodeId] ?? {}),
440
+ ...(run.callResults[nodeId]?.tier === undefined ? {} : { tier: run.callResults[nodeId].tier }),
441
+ };
442
+ }
443
+
420
444
  function parseOwner(raw: unknown, runId: string, nodeId: string): WorkflowOwner {
421
445
  if (!isRecord(raw) || raw.extension !== "pi-workflows") throw new Error("journal owner is invalid");
422
446
  const ownerRunId = boundedString(raw.runId, "owner.runId", JOURNAL_ID_LIMIT);
@@ -486,6 +510,7 @@ function parseRotation(raw: unknown, runId: string): RecoveryRotation {
486
510
  if (generation <= sourceGeneration) throw new Error("rotation generation must exceed its source");
487
511
  const owner = parseOwner(raw.owner, runId, nodeId);
488
512
  if (owner.attemptId !== attemptId) throw new Error("rotation owner does not match its attempt");
513
+ const tierIdentity = parseCallTierIdentity(raw, "rotation");
489
514
  return {
490
515
  nodeId,
491
516
  sourceAttemptId,
@@ -494,6 +519,7 @@ function parseRotation(raw: unknown, runId: string): RecoveryRotation {
494
519
  attemptId,
495
520
  generation,
496
521
  owner,
522
+ ...tierIdentity,
497
523
  ...(raw.supersededAgentId === undefined
498
524
  ? {}
499
525
  : { supersededAgentId: boundedString(raw.supersededAgentId, "rotation.supersededAgentId", JOURNAL_ID_LIMIT) }),
@@ -523,7 +549,9 @@ function parseRunRecovery(raw: Record<string, unknown>, run: ScriptRun, timestam
523
549
  raw.terminalResults === undefined
524
550
  ? undefined
525
551
  : (() => {
526
- if (!Array.isArray(raw.terminalResults)) throw new Error("run recovery terminalResults is invalid");
552
+ if (!Array.isArray(raw.terminalResults) || raw.terminalResults.length > JOURNAL_RECOVERY_NODE_LIMIT) {
553
+ throw new Error("run recovery terminalResults are invalid");
554
+ }
527
555
  return raw.terminalResults.map((terminal) => parseTerminalResult(terminal, run.runId));
528
556
  })();
529
557
  const recoveryId = boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT);
@@ -588,7 +616,6 @@ function parseAttemptRecovery(raw: Record<string, unknown>, run: ScriptRun, time
588
616
  nodeId: rotation.nodeId,
589
617
  branchGeneration: raw.branchGeneration,
590
618
  rotation,
591
- ...(raw.legacyMigration === true ? { legacyMigration: true } : {}),
592
619
  timestamp,
593
620
  };
594
621
  }
@@ -600,53 +627,20 @@ export function recoverySemanticFingerprint(event: RunRecoveryEvent | TerminalRe
600
627
  }
601
628
 
602
629
  function parseDuplicateRunRecovery(raw: Record<string, unknown>, run: ScriptRun): RunRecoveryEvent {
603
- if (raw.status !== "interrupted") throw new Error("run recovery status is invalid");
604
- if (!Array.isArray(raw.rotations) || raw.rotations.length > JOURNAL_RECOVERY_NODE_LIMIT) {
605
- throw new Error("run recovery rotations are invalid");
606
- }
607
- const rotations = raw.rotations.map((rotation) => parseRotation(rotation, run.runId));
608
- const terminalResults =
609
- raw.terminalResults === undefined
610
- ? undefined
611
- : (() => {
612
- if (!Array.isArray(raw.terminalResults)) throw new Error("run recovery terminalResults is invalid");
613
- return raw.terminalResults.map((terminal) => parseTerminalResult(terminal, run.runId));
614
- })();
615
- return {
616
- kind: "run_recovery",
617
- schemaVersion: JOURNAL_SCHEMA_VERSION,
618
- runId: run.runId,
619
- status: "interrupted",
620
- branchGeneration: run.updatedAt, // not meaningful for duplicates; fingerprint covers content
621
- rotations,
622
- ...(terminalResults === undefined || terminalResults.length === 0 ? {} : { terminalResults }),
623
- recoveryId: boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT),
624
- timestamp: run.updatedAt,
625
- };
630
+ // Duplicate detection must fingerprint the same semantic event as the normal
631
+ // parser. In particular, preserve branchGeneration and timestamp; replacing
632
+ // either with current run state turns an idempotent retry into a conflict.
633
+ return parseRunRecovery(raw, run, validTimestamp(raw.timestamp, "timestamp"));
626
634
  }
627
635
 
628
636
  function parseDuplicateTerminalRecovery(raw: Record<string, unknown>, run: ScriptRun): TerminalRecoveryEvent {
629
- if (raw.status !== "failed" && raw.status !== "stopped") throw new Error("terminal recovery status is invalid");
630
- if (!Array.isArray(raw.terminalResults) || raw.terminalResults.length > JOURNAL_RECOVERY_NODE_LIMIT) {
631
- throw new Error("terminal recovery terminalResults are invalid");
632
- }
633
- const terminalResults = raw.terminalResults.map((terminal) => parseTerminalResult(terminal, run.runId));
634
- return {
635
- kind: "terminal_recovery",
636
- schemaVersion: JOURNAL_SCHEMA_VERSION,
637
- runId: run.runId,
638
- status: raw.status,
639
- terminalIntent: raw.terminalIntent === "failure" ? "failure" : "stop",
640
- branchGeneration: run.updatedAt,
641
- terminalResults,
642
- blockedNodeIds: [],
643
- recoveryId: boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT),
644
- timestamp: run.updatedAt,
645
- };
637
+ // Preserve every semantic field, including blocked nodes, error, branch
638
+ // generation, and timestamp, so an at-least-once append is truly idempotent.
639
+ return parseTerminalRecovery(raw, run, validTimestamp(raw.timestamp, "timestamp"));
646
640
  }
647
641
 
648
- function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean | undefined {
649
- if (raw.frozenArgsPresent === undefined) return undefined;
642
+ function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean {
643
+ if (raw.frozenArgsPresent === undefined) throw new Error("frozen args presence marker is required");
650
644
  if (typeof raw.frozenArgsPresent !== "boolean") throw new Error("frozen args presence marker must be boolean");
651
645
  const hasArgs = Object.hasOwn(raw, "args");
652
646
  if (raw.frozenArgsPresent && (!hasArgs || raw.args === undefined)) {
@@ -658,15 +652,13 @@ function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean | undefi
658
652
  return raw.frozenArgsPresent;
659
653
  }
660
654
 
661
- function parseEvent(
662
- raw: unknown,
663
- runs: Map<string, ScriptRun>,
664
- legacyArgsBackfilled: ReadonlySet<string>,
665
- ): JournalEvent {
655
+ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
666
656
  if (!isRecord(raw)) throw new Error("journal event is not an object");
667
657
  if (typeof raw.kind !== "string" || !JOURNAL_KINDS.has(raw.kind)) throw new Error("journal event kind is unknown");
668
- if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION && raw.schemaVersion !== JOURNAL_LEGACY_SCHEMA_VERSION) {
669
- throw new Error("journal schema version is unsupported");
658
+ if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) {
659
+ throw new Error(
660
+ `journal schema version ${String(raw.schemaVersion)} is unsupported; expected ${JOURNAL_SCHEMA_VERSION}`,
661
+ );
670
662
  }
671
663
  const runId = boundedString(raw.runId, "runId", JOURNAL_ID_LIMIT);
672
664
  const timestamp = validTimestamp(raw.timestamp, "timestamp");
@@ -678,46 +670,67 @@ function parseEvent(
678
670
  const meta = parseMeta(raw.meta);
679
671
  const frozenArgsPresent = parseFrozenArgsPresence(raw);
680
672
  const args = raw.args === undefined ? undefined : parsePersistedValue(raw.args, "workflow args");
681
- const toolset =
682
- raw.toolset === undefined
683
- ? undefined
684
- : typeof raw.toolset === "string" && raw.toolset.length > 0 && raw.toolset.length <= 64
685
- ? raw.toolset
686
- : undefined;
687
- const frozenMaxAgents =
688
- typeof raw.frozenMaxAgents === "number" && Number.isFinite(raw.frozenMaxAgents) && raw.frozenMaxAgents >= 1
689
- ? normalizeMaxAgents(raw.frozenMaxAgents)
690
- : undefined;
691
- const frozenConcurrency =
692
- typeof raw.frozenConcurrency === "number" && Number.isFinite(raw.frozenConcurrency) && raw.frozenConcurrency >= 1
693
- ? normalizeConcurrency(raw.frozenConcurrency)
694
- : undefined;
695
- const frozenAgentRetries =
696
- typeof raw.frozenAgentRetries === "number" &&
697
- Number.isFinite(raw.frozenAgentRetries) &&
698
- raw.frozenAgentRetries >= 0
699
- ? normalizeAgentRetries(raw.frozenAgentRetries)
700
- : undefined;
701
- const frozenTokenBudget =
702
- raw.frozenTokenBudget === null
703
- ? null
704
- : typeof raw.frozenTokenBudget === "number" &&
705
- Number.isFinite(raw.frozenTokenBudget) &&
706
- raw.frozenTokenBudget >= 1
707
- ? normalizeTokenBudget(raw.frozenTokenBudget)
708
- : undefined;
709
- const frozenAgentTimeoutMs =
710
- raw.frozenAgentTimeoutMs === null
711
- ? null
712
- : typeof raw.frozenAgentTimeoutMs === "number" &&
713
- Number.isFinite(raw.frozenAgentTimeoutMs) &&
714
- raw.frozenAgentTimeoutMs >= 1
715
- ? normalizeAgentTimeout(raw.frozenAgentTimeoutMs)
716
- : undefined;
717
- const frozenExcludeTools =
718
- Array.isArray(raw.frozenExcludeTools) && raw.frozenExcludeTools.every((v) => typeof v === "string")
719
- ? (raw.frozenExcludeTools as string[])
720
- : undefined;
673
+ const toolset = Object.hasOwn(raw, "toolset") ? boundedString(raw.toolset, "toolset", 64) : undefined;
674
+ const frozenMaxAgents = (() => {
675
+ if (!Object.hasOwn(raw, "frozenMaxAgents")) return undefined;
676
+ if (typeof raw.frozenMaxAgents !== "number" || !Number.isFinite(raw.frozenMaxAgents) || raw.frozenMaxAgents < 1) {
677
+ throw new Error("frozenMaxAgents is invalid");
678
+ }
679
+ return normalizeMaxAgents(raw.frozenMaxAgents);
680
+ })();
681
+ const frozenConcurrency = (() => {
682
+ if (!Object.hasOwn(raw, "frozenConcurrency")) return undefined;
683
+ if (
684
+ typeof raw.frozenConcurrency !== "number" ||
685
+ !Number.isFinite(raw.frozenConcurrency) ||
686
+ raw.frozenConcurrency < 1
687
+ ) {
688
+ throw new Error("frozenConcurrency is invalid");
689
+ }
690
+ return normalizeConcurrency(raw.frozenConcurrency);
691
+ })();
692
+ const frozenAgentRetries = (() => {
693
+ if (!Object.hasOwn(raw, "frozenAgentRetries")) return undefined;
694
+ if (
695
+ typeof raw.frozenAgentRetries !== "number" ||
696
+ !Number.isFinite(raw.frozenAgentRetries) ||
697
+ raw.frozenAgentRetries < 0
698
+ ) {
699
+ throw new Error("frozenAgentRetries is invalid");
700
+ }
701
+ return normalizeAgentRetries(raw.frozenAgentRetries);
702
+ })();
703
+ const frozenTokenBudget = (() => {
704
+ if (!Object.hasOwn(raw, "frozenTokenBudget")) return undefined;
705
+ if (raw.frozenTokenBudget === null) return null;
706
+ if (
707
+ typeof raw.frozenTokenBudget !== "number" ||
708
+ !Number.isFinite(raw.frozenTokenBudget) ||
709
+ raw.frozenTokenBudget < 1
710
+ ) {
711
+ throw new Error("frozenTokenBudget is invalid");
712
+ }
713
+ return normalizeTokenBudget(raw.frozenTokenBudget);
714
+ })();
715
+ const frozenAgentTimeoutMs = (() => {
716
+ if (!Object.hasOwn(raw, "frozenAgentTimeoutMs")) return undefined;
717
+ if (raw.frozenAgentTimeoutMs === null) return null;
718
+ if (
719
+ typeof raw.frozenAgentTimeoutMs !== "number" ||
720
+ !Number.isFinite(raw.frozenAgentTimeoutMs) ||
721
+ raw.frozenAgentTimeoutMs < 1
722
+ ) {
723
+ throw new Error("frozenAgentTimeoutMs is invalid");
724
+ }
725
+ return normalizeAgentTimeout(raw.frozenAgentTimeoutMs);
726
+ })();
727
+ const frozenExcludeTools = (() => {
728
+ if (!Object.hasOwn(raw, "frozenExcludeTools")) return undefined;
729
+ if (!Array.isArray(raw.frozenExcludeTools) || !raw.frozenExcludeTools.every((v) => typeof v === "string")) {
730
+ throw new Error("frozenExcludeTools is invalid");
731
+ }
732
+ return [...raw.frozenExcludeTools] as string[];
733
+ })();
721
734
  return {
722
735
  kind: "run_created",
723
736
  schemaVersion: raw.schemaVersion as JournalSchemaVersion,
@@ -726,7 +739,7 @@ function parseEvent(
726
739
  scriptHash,
727
740
  meta,
728
741
  ...(args === undefined ? {} : { args }),
729
- ...(frozenArgsPresent === undefined ? {} : { frozenArgsPresent }),
742
+ frozenArgsPresent,
730
743
  ...(toolset ? { toolset } : {}),
731
744
  ...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
732
745
  ...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
@@ -742,21 +755,6 @@ function parseEvent(
742
755
 
743
756
  const run = runs.get(runId);
744
757
  if (!run) throw new Error("journal event references an unknown run");
745
- if (raw.kind === "run_args") {
746
- if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("run args schema version is unsupported");
747
- if (!Object.hasOwn(raw, "args") || raw.args === undefined) throw new Error("run args fact is missing args");
748
- if (run.schemaVersion !== JOURNAL_SCHEMA_VERSION || run.frozenArgsPresent !== undefined) {
749
- throw new Error("run args fact cannot override frozen workflow args presence");
750
- }
751
- const args = parsePersistedValue(raw.args, "run args");
752
- if (
753
- run.args !== undefined &&
754
- (!legacyArgsBackfilled.has(runId) || JSON.stringify(run.args) !== JSON.stringify(args))
755
- ) {
756
- throw new Error("conflicting workflow run args");
757
- }
758
- return { kind: "run_args", schemaVersion: JOURNAL_SCHEMA_VERSION, runId, args, timestamp };
759
- }
760
758
  if (raw.kind === "run_revision") {
761
759
  const revision = raw.revision;
762
760
  if (typeof revision !== "number" || !Number.isInteger(revision) || revision <= (run.revision ?? 0))
@@ -819,7 +817,10 @@ function parseEvent(
819
817
  if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("workflow result schema version is unsupported");
820
818
  if (raw.owner !== undefined || raw.attemptId !== undefined)
821
819
  throw new Error("workflow result cannot carry a managed attempt");
822
- const result = parseResult(raw.result, "workflow result");
820
+ const result = mergeCallResultTierIdentity(
821
+ priorCallTierIdentity(run, nodeId),
822
+ parseResult(raw.result, "workflow result"),
823
+ );
823
824
  if (result.status !== "completed") throw new Error("workflow result must be completed");
824
825
  if (result.attemptId !== undefined) throw new Error("workflow result payload cannot carry a managed attempt");
825
826
  const generation = raw.generation;
@@ -895,6 +896,7 @@ function parseEvent(
895
896
  if (attemptId !== expectedAttemptId || owner?.attemptId !== attemptId) {
896
897
  throw new Error("call attempt does not identify its next generation");
897
898
  }
899
+ const tierIdentity = parseCallTierIdentity(raw, "call attempt");
898
900
  return {
899
901
  kind: "call_attempt",
900
902
  schemaVersion: JOURNAL_SCHEMA_VERSION,
@@ -902,6 +904,7 @@ function parseEvent(
902
904
  nodeId,
903
905
  attemptId,
904
906
  generation,
907
+ ...tierIdentity,
905
908
  timestamp,
906
909
  ...(owner ? { owner } : {}),
907
910
  };
@@ -910,12 +913,14 @@ function parseEvent(
910
913
  if (raw.kind === "call_transition") {
911
914
  if (!CALL_STATUS_VALUES.has(raw.status as CallStatus)) throw new Error("call status is unknown");
912
915
  const agentId = raw.agentId === undefined ? undefined : boundedString(raw.agentId, "agentId", JOURNAL_ID_LIMIT);
916
+ const tierIdentity = parseCallTierIdentity(raw, "call transition");
913
917
  return {
914
918
  kind: "call_transition",
915
919
  schemaVersion: JOURNAL_SCHEMA_VERSION,
916
920
  runId,
917
921
  nodeId,
918
922
  status: raw.status as CallStatus,
923
+ ...tierIdentity,
919
924
  timestamp,
920
925
  ...(agentId ? { agentId } : {}),
921
926
  ...(attemptId ? { attemptId } : {}),
@@ -930,12 +935,14 @@ function parseEvent(
930
935
  if (result.attemptId && attemptId && result.attemptId !== attemptId) {
931
936
  throw new StaleJournalGenerationError("call result payload references a superseded attempt");
932
937
  }
938
+ const normalizedResult = result.attemptId === undefined && attemptId ? { ...result, attemptId } : result;
939
+ const resultWithTierIdentity = mergeCallResultTierIdentity(priorCallTierIdentity(run, nodeId), normalizedResult);
933
940
  return {
934
941
  kind: "call_result",
935
942
  schemaVersion: JOURNAL_SCHEMA_VERSION,
936
943
  runId,
937
944
  nodeId,
938
- result: result.attemptId === undefined && attemptId ? { ...result, attemptId } : result,
945
+ result: resultWithTierIdentity,
939
946
  ...(raw.callHash === undefined ? {} : { callHash: boundedString(raw.callHash, "callHash", 128) }),
940
947
  ...(raw.storeDelta === undefined
941
948
  ? {}
@@ -969,7 +976,8 @@ export function replayJournal(
969
976
  const runs = new Map<string, ScriptRun>();
970
977
  const quarantined = new Set<string>();
971
978
  const removed = new Set<string>();
972
- const legacyArgsBackfilled = new Set<string>();
979
+ /** Parsed creation facts are retained separately from mutable run state for duplicate checks. */
980
+ const creationFingerprints = new Map<string, string>();
973
981
  /** Recovery identities are scoped to one replayed run and bounded explicitly. */
974
982
  const seenRecoveries = new Map<string, Map<string, string>>();
975
983
  let reported = false;
@@ -984,18 +992,28 @@ export function replayJournal(
984
992
  const rawRunId = isRecord(raw) && typeof raw.runId === "string" ? raw.runId : undefined;
985
993
  if (rawRunId && quarantined.has(rawRunId)) continue;
986
994
  if (rawRunId && removed.has(rawRunId)) continue;
995
+ // Set inside the guarded region, delivered outside it. An observer that
996
+ // throws is reporting that the accepted facts contradict each other, which
997
+ // is exactly the condition the caller needs to see — running it inside the
998
+ // `try` would quarantine the run and swallow the diagnostic, turning a real
999
+ // conflict into a silently missing cache entry. `finally` also delivers on
1000
+ // the `continue` paths, which a trailing statement would skip.
1001
+ let accepted: JournalEvent | undefined;
987
1002
  try {
988
- if (isRecord(raw) && raw.kind === "run_created" && rawRunId && runs.has(rawRunId)) {
989
- const existing = runs.get(rawRunId);
990
- if (
991
- existing &&
992
- existing.script === raw.script &&
993
- existing.scriptHash === raw.scriptHash &&
994
- JSON.stringify(existing.meta) === JSON.stringify(raw.meta) &&
995
- existing.frozenArgsPresent === parseFrozenArgsPresence(raw) &&
996
- JSON.stringify(existing.args) === JSON.stringify(raw.args)
997
- )
998
- continue;
1003
+ if (
1004
+ isRecord(raw) &&
1005
+ raw.schemaVersion === JOURNAL_SCHEMA_VERSION &&
1006
+ raw.kind === "run_created" &&
1007
+ rawRunId &&
1008
+ runs.has(rawRunId)
1009
+ ) {
1010
+ // Parse against an empty run map so every creation field participates in
1011
+ // the comparison, including frozen limits and attempt seeds. Comparing
1012
+ // against mutable run state would make a harmless retry conflict after
1013
+ // later call/recovery events have changed that state.
1014
+ const candidate = parseEvent(raw, new Map());
1015
+ const candidateFingerprint = JSON.stringify(candidate);
1016
+ if (creationFingerprints.get(rawRunId) === candidateFingerprint) continue;
999
1017
  throw new Error("conflicting duplicate workflow run_created event");
1000
1018
  }
1001
1019
  if (
@@ -1008,7 +1026,12 @@ export function replayJournal(
1008
1026
  throw new Error(`recoveryId ${raw.recoveryId} is already owned by workflow run ${seenRunId}`);
1009
1027
  }
1010
1028
  }
1011
- if (isRecord(raw) && (raw.kind === "run_recovery" || raw.kind === "terminal_recovery") && rawRunId) {
1029
+ if (
1030
+ isRecord(raw) &&
1031
+ raw.schemaVersion === JOURNAL_SCHEMA_VERSION &&
1032
+ (raw.kind === "run_recovery" || raw.kind === "terminal_recovery") &&
1033
+ rawRunId
1034
+ ) {
1012
1035
  const run = runs.get(rawRunId);
1013
1036
  const seen = seenRecoveries.get(rawRunId);
1014
1037
  if (run && seen) {
@@ -1027,11 +1050,13 @@ export function replayJournal(
1027
1050
  }
1028
1051
  }
1029
1052
 
1030
- const event = parseEvent(raw, runs, legacyArgsBackfilled);
1053
+ const event = parseEvent(raw, runs);
1031
1054
  if (quarantined.has(event.runId)) continue;
1032
1055
  if (event.kind === "run_created") {
1033
1056
  applyJournalEvent(runs, event);
1057
+ creationFingerprints.set(event.runId, JSON.stringify(event));
1034
1058
  seenRecoveries.set(event.runId, new Map());
1059
+ accepted = event;
1035
1060
  continue;
1036
1061
  }
1037
1062
  if (event.kind === "run_recovery" || event.kind === "terminal_recovery") {
@@ -1050,23 +1075,25 @@ export function replayJournal(
1050
1075
  applyJournalEvent(runs, event);
1051
1076
  seen.set(event.recoveryId, recoverySemanticFingerprint(event));
1052
1077
  seenRecoveries.set(event.runId, seen);
1078
+ accepted = event;
1053
1079
  continue;
1054
1080
  }
1055
1081
  applyJournalEvent(runs, event);
1056
- if (event.kind === "run_args") legacyArgsBackfilled.add(event.runId);
1082
+ accepted = event;
1057
1083
  if (event.kind === "run_removed") {
1058
1084
  removed.add(event.runId);
1059
- legacyArgsBackfilled.delete(event.runId);
1060
1085
  }
1061
1086
  } catch (error: unknown) {
1062
1087
  if (error instanceof StaleJournalGenerationError) continue;
1063
1088
  if (rawRunId) {
1064
1089
  quarantined.add(rawRunId);
1065
1090
  runs.delete(rawRunId);
1091
+ creationFingerprints.delete(rawRunId);
1066
1092
  seenRecoveries.delete(rawRunId);
1067
- legacyArgsBackfilled.delete(rawRunId);
1068
1093
  }
1069
1094
  report(error instanceof Error ? error.message : String(error));
1095
+ } finally {
1096
+ if (accepted) options.onAccepted?.(accepted);
1070
1097
  }
1071
1098
  }
1072
1099
  for (const [runId, run] of runs) {
@@ -1075,6 +1102,7 @@ export function replayJournal(
1075
1102
  } catch (error: unknown) {
1076
1103
  runs.delete(runId);
1077
1104
  quarantined.add(runId);
1105
+ creationFingerprints.delete(runId);
1078
1106
  seenRecoveries.delete(runId);
1079
1107
  report(`${runId}: ${error instanceof Error ? error.message : String(error)}`);
1080
1108
  }
@@ -1088,15 +1116,19 @@ function applyRecoveryRotation(run: ScriptRun, rotation: RecoveryRotation): void
1088
1116
  run.attemptIds[rotation.nodeId] = rotation.attemptId;
1089
1117
  delete run.agentIds[rotation.nodeId];
1090
1118
  delete run.callResults[rotation.nodeId];
1119
+ const rotationTier = rotation.tier ?? run.callTiers[rotation.nodeId]?.tier;
1120
+ if (rotationTier !== undefined) run.callTiers[rotation.nodeId] = { tier: rotationTier };
1091
1121
  run.compactions[rotation.nodeId] = 0;
1092
1122
  run.callStatus[rotation.nodeId] = "running";
1093
1123
  }
1094
1124
 
1095
1125
  function applyRecoveryTerminalResult(run: ScriptRun, terminal: RecoveryTerminalResult): void {
1096
1126
  run.compactions[terminal.nodeId] = Math.max(run.compactions[terminal.nodeId] ?? 0, terminal.result.compactionCount);
1097
- run.callResults[terminal.nodeId] = terminal.result;
1098
- run.callStatus[terminal.nodeId] = terminal.result.status;
1099
- if (terminal.result.agentId) run.agentIds[terminal.nodeId] = terminal.result.agentId;
1127
+ const result = mergeCallResultTierIdentity(priorCallTierIdentity(run, terminal.nodeId), terminal.result);
1128
+ run.callResults[terminal.nodeId] = result;
1129
+ run.callTiers[terminal.nodeId] = result.tier === undefined ? {} : { tier: result.tier };
1130
+ run.callStatus[terminal.nodeId] = result.status;
1131
+ if (result.agentId) run.agentIds[terminal.nodeId] = result.agentId;
1100
1132
  }
1101
1133
 
1102
1134
  export function applyRecoveryTerminalResults(run: ScriptRun, terminalResults: readonly RecoveryTerminalResult[]): void {
@@ -1145,7 +1177,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1145
1177
  scriptHash: event.scriptHash,
1146
1178
  meta: event.meta,
1147
1179
  ...(event.args === undefined ? {} : { args: structuredClone(event.args) }),
1148
- ...(event.frozenArgsPresent === undefined ? {} : { frozenArgsPresent: event.frozenArgsPresent }),
1180
+ frozenArgsPresent: event.frozenArgsPresent,
1149
1181
  revision: 0,
1150
1182
  ...(event.toolset ? { toolset: event.toolset } : {}),
1151
1183
  ...(event.frozenMaxAgents !== undefined ? { frozenMaxAgents: event.frozenMaxAgents } : {}),
@@ -1161,6 +1193,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1161
1193
  attemptIds,
1162
1194
  attemptTracking: event.attemptIds !== undefined,
1163
1195
  callResults: {},
1196
+ callTiers: {},
1164
1197
  workflowResultGenerations: {},
1165
1198
  compactions: {},
1166
1199
  startedAt: event.timestamp,
@@ -1177,9 +1210,6 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1177
1210
  }
1178
1211
  run.updatedAt = event.timestamp;
1179
1212
  switch (event.kind) {
1180
- case "run_args":
1181
- run.args = structuredClone(event.args);
1182
- break;
1183
1213
  case "run_revision":
1184
1214
  run.revision = event.revision;
1185
1215
  run.script = event.script;
@@ -1205,6 +1235,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1205
1235
  run.attemptIds[event.nodeId] = event.attemptId;
1206
1236
  delete run.agentIds[event.nodeId];
1207
1237
  delete run.callResults[event.nodeId];
1238
+ if (event.tier !== undefined) run.callTiers[event.nodeId] = { tier: event.tier };
1208
1239
  run.callStatus[event.nodeId] = "running";
1209
1240
  run.compactions[event.nodeId] = 0;
1210
1241
  break;
@@ -1212,30 +1243,42 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1212
1243
  if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
1213
1244
  throw new Error("call transition references a superseded attempt");
1214
1245
  }
1215
- run.callStatus[event.nodeId] = event.status;
1246
+ if (event.tier !== undefined) run.callTiers[event.nodeId] = { tier: event.tier };
1247
+ // Identity-only transitions use status "running". A delayed retry must
1248
+ // not regress a terminal call result that was already replayed.
1249
+ if (
1250
+ event.status !== "running" ||
1251
+ run.callStatus[event.nodeId] === undefined ||
1252
+ run.callStatus[event.nodeId] === "running"
1253
+ ) {
1254
+ run.callStatus[event.nodeId] = event.status;
1255
+ }
1216
1256
  if (event.agentId) run.agentIds[event.nodeId] = event.agentId;
1217
1257
  break;
1218
- case "workflow_result":
1258
+ case "workflow_result": {
1219
1259
  run.workflowResultGenerations[event.nodeId] = event.generation;
1220
- run.callResults[event.nodeId] = event.result;
1221
- run.callStatus[event.nodeId] = event.result.status;
1222
- run.compactions[event.nodeId] = event.result.compactionCount;
1260
+ const result = mergeCallResultTierIdentity(priorCallTierIdentity(run, event.nodeId), event.result);
1261
+ run.callResults[event.nodeId] = result;
1262
+ run.callTiers[event.nodeId] = result.tier === undefined ? {} : { tier: result.tier };
1263
+ run.callStatus[event.nodeId] = result.status;
1264
+ run.compactions[event.nodeId] = result.compactionCount;
1223
1265
  break;
1224
- case "call_result":
1266
+ }
1267
+ case "call_result": {
1225
1268
  if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
1226
1269
  throw new Error("call result references a superseded attempt");
1227
1270
  }
1228
- if (
1229
- run.callResults[event.nodeId] &&
1230
- JSON.stringify(run.callResults[event.nodeId]) !== JSON.stringify(event.result)
1231
- ) {
1271
+ const result = mergeCallResultTierIdentity(priorCallTierIdentity(run, event.nodeId), event.result);
1272
+ if (run.callResults[event.nodeId] && JSON.stringify(run.callResults[event.nodeId]) !== JSON.stringify(result)) {
1232
1273
  throw new Error("contradictory terminal call result");
1233
1274
  }
1234
- run.callResults[event.nodeId] = event.result;
1235
- run.callStatus[event.nodeId] = event.result.status;
1236
- run.compactions[event.nodeId] = Math.max(run.compactions[event.nodeId] ?? 0, event.result.compactionCount);
1237
- if (event.result.agentId) run.agentIds[event.nodeId] = event.result.agentId;
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] = Math.max(run.compactions[event.nodeId] ?? 0, result.compactionCount);
1279
+ if (result.agentId) run.agentIds[event.nodeId] = result.agentId;
1238
1280
  break;
1281
+ }
1239
1282
  }
1240
1283
  }
1241
1284
 
@@ -1310,9 +1353,9 @@ export function snapshotRun(run: ScriptRun): ScriptRun {
1310
1353
  * The runtime keys its cache by `${runId}:${callIndex}` (the same namespacing
1311
1354
  * the store deltas use), so a nested workflow() call with its own runId can
1312
1355
  * never replay the parent's entry. Every `call_result` or `workflow_result`
1313
- * event that carries a `callHash` is a replayable fact; entries without a hash
1314
- * (legacy or malformed) are skipped rather than quarantined — a resume must
1315
- * tolerate a foreign or partial journal and simply re-run the misses live.
1356
+ * event that carries a `callHash` is a replayable fact. Entries from another
1357
+ * schema or without a hash are not replayable; they are ignored here even when
1358
+ * `replayJournal` retains them as non-replayable lifecycle history.
1316
1359
  */
1317
1360
  export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
1318
1361
  string,
@@ -1321,88 +1364,70 @@ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
1321
1364
  runId?: string;
1322
1365
  hash: string;
1323
1366
  result: unknown;
1367
+ tier?: string;
1324
1368
  storeDelta?: Record<string, unknown>;
1325
1369
  generation?: number;
1326
1370
  agentCount?: number;
1327
1371
  }
1328
1372
  > {
1329
- const journal = new Map<
1330
- string,
1331
- {
1332
- index: number;
1333
- runId?: string;
1334
- hash: string;
1335
- result: unknown;
1336
- storeDelta?: Record<string, unknown>;
1337
- generation?: number;
1338
- agentCount?: number;
1339
- }
1340
- >();
1341
- for (const entry of entries) {
1342
- if (entry.type !== "custom" || entry.customType !== JOURNAL_ENTRY_TYPE) continue;
1343
- const raw = entry.data;
1344
- if (
1345
- !isRecord(raw) ||
1346
- (raw.kind !== "call_result" && raw.kind !== "workflow_result") ||
1347
- typeof raw.callHash !== "string"
1348
- )
1349
- continue;
1350
- if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) continue;
1351
- const runId = boundedString(raw.runId, "runId", JOURNAL_ID_LIMIT);
1352
- let nodeId: string;
1353
- try {
1354
- nodeId = parseCallIndex(raw.nodeId, "nodeId");
1355
- } catch {
1356
- continue;
1357
- }
1358
- const result = isRecord(raw.result) ? raw.result : undefined;
1359
- // Failed/stopped lifecycle facts are diagnostic history, not successful
1360
- // replay values. Replaying them as `undefined` would silently skip a call
1361
- // and bypass the runtime's retry/error policy.
1362
- if (result?.status !== "completed" || !Object.hasOwn(result, "result")) continue;
1363
- const generation =
1364
- raw.kind === "workflow_result" && raw.generation !== undefined
1365
- ? typeof raw.generation === "number" &&
1366
- Number.isInteger(raw.generation) &&
1367
- raw.generation >= 1 &&
1368
- raw.generation <= 1_000_000
1369
- ? raw.generation
1370
- : undefined
1371
- : undefined;
1372
- if (raw.kind === "workflow_result" && generation === undefined) continue;
1373
- const agentCount =
1374
- raw.kind === "workflow_result" && raw.agentCount !== undefined
1375
- ? typeof raw.agentCount === "number" &&
1376
- Number.isInteger(raw.agentCount) &&
1377
- raw.agentCount >= 0 &&
1378
- raw.agentCount <= JOURNAL_CALL_LIMIT
1379
- ? raw.agentCount
1380
- : undefined
1381
- : undefined;
1382
- if (raw.kind === "workflow_result" && raw.agentCount !== undefined && agentCount === undefined) continue;
1383
- const candidate = {
1384
- index: Number(nodeId),
1385
- runId,
1386
- hash: raw.callHash,
1387
- result: result.result,
1388
- ...(generation === undefined ? {} : { generation }),
1389
- ...(agentCount === undefined ? {} : { agentCount }),
1390
- ...(isRecord(raw.storeDelta) && Object.keys(raw.storeDelta).length > 0
1391
- ? { storeDelta: raw.storeDelta as Record<string, unknown> }
1392
- : {}),
1393
- };
1394
- const key = `${runId}:${nodeId}`;
1395
- const existing = journal.get(key);
1396
- if (generation !== undefined && existing?.generation !== undefined) {
1397
- if (generation < existing.generation) continue;
1398
- if (generation === existing.generation) {
1399
- if (JSON.stringify(existing) !== JSON.stringify(candidate)) {
1400
- throw new Error("contradictory nested workflow result");
1373
+ type ResumeEntry = {
1374
+ index: number;
1375
+ runId?: string;
1376
+ hash: string;
1377
+ result: unknown;
1378
+ tier?: string;
1379
+ storeDelta?: Record<string, unknown>;
1380
+ generation?: number;
1381
+ agentCount?: number;
1382
+ };
1383
+ const journal = new Map<string, ResumeEntry>();
1384
+ const journalRunIds = new Map<string, string>();
1385
+
1386
+ // Reuse the authoritative replay validator so stale call results from an
1387
+ // older attempt, terminal-after-lifecycle facts, and quarantined runs never
1388
+ // enter the cache merely because they appear later in the session branch.
1389
+ const replayedRuns = replayJournal(entries, {
1390
+ onAccepted: (event) => {
1391
+ if ((event.kind !== "call_result" && event.kind !== "workflow_result") || event.callHash === undefined) return;
1392
+ if (event.result.status !== "completed" || !Object.hasOwn(event.result, "result")) return;
1393
+
1394
+ const candidate: ResumeEntry = {
1395
+ index: Number(event.nodeId),
1396
+ runId: event.runId,
1397
+ hash: event.callHash,
1398
+ result: event.result.result,
1399
+ ...(event.result.tier === undefined ? {} : { tier: event.result.tier }),
1400
+ ...(event.kind === "workflow_result" ? { generation: event.generation } : {}),
1401
+ ...(event.kind === "workflow_result" && event.agentCount !== undefined ? { agentCount: event.agentCount } : {}),
1402
+ ...(event.storeDelta !== undefined && Object.keys(event.storeDelta).length > 0
1403
+ ? { storeDelta: event.storeDelta }
1404
+ : {}),
1405
+ };
1406
+ const key = `${event.runId}:${event.nodeId}`;
1407
+ const existing = journal.get(key);
1408
+ if (candidate.generation !== undefined && existing?.generation !== undefined) {
1409
+ if (candidate.generation < existing.generation) return;
1410
+ // Same generation, same content is an at-least-once retry. Same
1411
+ // generation, different content is a contradiction `replayJournal` has
1412
+ // already rejected before accepting the second fact — reaching here
1413
+ // would mean the two validators disagree, so surface it rather than
1414
+ // quietly keeping whichever arrived first.
1415
+ if (candidate.generation === existing.generation) {
1416
+ if (JSON.stringify(existing) !== JSON.stringify(candidate))
1417
+ throw new Error("contradictory nested workflow result");
1418
+ return;
1401
1419
  }
1402
- continue;
1403
1420
  }
1404
- }
1405
- journal.set(key, candidate);
1421
+ journal.set(key, candidate);
1422
+ journalRunIds.set(key, event.runId);
1423
+ },
1424
+ });
1425
+
1426
+ // replayJournal may quarantine a run after an accepted fact (for example,
1427
+ // when a later contradictory event invalidates the aggregate). Do not retain
1428
+ // cache entries belonging to that run.
1429
+ for (const [key, runId] of journalRunIds) {
1430
+ if (!replayedRuns.has(runId)) journal.delete(key);
1406
1431
  }
1407
1432
  return journal;
1408
1433
  }