@signalridge/pi-workflows 1.4.0 → 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,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,10 +129,16 @@ 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;
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;
132
142
  /** Toolset marker persisted across resume (e.g. "web-research" for deep-research). */
133
143
  toolset?: string;
134
144
  /** Frozen run params — persisted so resume after restore keeps original budget/scale. */
@@ -148,6 +158,8 @@ export interface ScriptRun {
148
158
  attemptTracking?: boolean;
149
159
  /** Call index → journaled result (the resume journal source). */
150
160
  callResults: Record<string, CallResult>;
161
+ /** Call index → resolved tier identity for live and stopped calls. */
162
+ callTiers: Record<string, CallTierIdentity>;
151
163
  /** Call index → latest accepted nested workflow result generation. */
152
164
  workflowResultGenerations: Record<string, number>;
153
165
  compactions: Record<string, number>;
@@ -170,7 +182,7 @@ interface JournalBase {
170
182
  /** State observed immediately before an atomic recovery rotation. */
171
183
  export type RecoverySourceStatus = CallStatus | "running";
172
184
 
173
- export interface RecoveryRotation {
185
+ export interface RecoveryRotation extends CallTierIdentity {
174
186
  nodeId: string;
175
187
  sourceAttemptId: string;
176
188
  sourceGeneration: number;
@@ -195,8 +207,6 @@ export interface RunRecoveryEvent extends JournalBase {
195
207
  rotations: RecoveryRotation[];
196
208
  terminalResults?: RecoveryTerminalResult[];
197
209
  recoveryId: string;
198
- /** Set only when reading a schema-v2 atomic event written before recoveryId existed. */
199
- legacyMigration?: true;
200
210
  timestamp: number;
201
211
  }
202
212
 
@@ -217,8 +227,6 @@ export interface AttemptRecoveryEvent extends JournalBase {
217
227
  nodeId: string;
218
228
  branchGeneration: number;
219
229
  rotation: RecoveryRotation;
220
- /** Only used to migrate a legacy prefix ending after workflow_transition. */
221
- legacyMigration?: true;
222
230
  }
223
231
 
224
232
  export type JournalEvent =
@@ -228,8 +236,9 @@ export type JournalEvent =
228
236
  scriptHash: string;
229
237
  meta: WorkflowMeta;
230
238
  args?: unknown;
231
- /** Explicit args presence marker; absent only on legacy schema-v3 facts. */
232
- frozenArgsPresent?: boolean;
239
+ /** Explicit args presence marker for deterministic resume. */
240
+ frozenArgsPresent: boolean;
241
+ shippedScript?: boolean;
233
242
  toolset?: string;
234
243
  frozenMaxAgents?: number;
235
244
  frozenConcurrency?: number;
@@ -240,11 +249,6 @@ export type JournalEvent =
240
249
  attempts?: Record<string, number>;
241
250
  attemptIds?: Record<string, string>;
242
251
  })
243
- | (JournalBase & {
244
- /** Canonical invocation args backfilled when resuming an older schema-v3 run. */
245
- kind: "run_args";
246
- args: unknown;
247
- })
248
252
  | (JournalBase & {
249
253
  kind: "run_revision";
250
254
  revision: number;
@@ -269,6 +273,7 @@ export type JournalEvent =
269
273
  kind: "call_transition";
270
274
  nodeId: string;
271
275
  status: CallStatus;
276
+ tier?: string;
272
277
  agentId?: string;
273
278
  attemptId?: string;
274
279
  owner?: WorkflowOwner;
@@ -278,6 +283,7 @@ export type JournalEvent =
278
283
  nodeId: string;
279
284
  attemptId: string;
280
285
  generation: number;
286
+ tier?: string;
281
287
  owner?: WorkflowOwner;
282
288
  })
283
289
  | (JournalBase & {
@@ -318,6 +324,8 @@ export interface SessionEntryLike {
318
324
 
319
325
  export interface ReplayJournalOptions {
320
326
  onInvalid?: (diagnostic: string) => void;
327
+ /** Internal observer for events that passed replay validation and were applied. */
328
+ onAccepted?: (event: JournalEvent) => void;
321
329
  }
322
330
 
323
331
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -362,23 +370,33 @@ function parseMeta(raw: unknown): WorkflowMeta {
362
370
  if (!isRecord(raw)) throw new Error("meta is invalid");
363
371
  const name = boundedString(raw.name, "meta.name", 512);
364
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
+ }
365
376
  const meta: WorkflowMeta = { name, description };
366
- if (raw.model !== undefined) meta.model = boundedString(raw.model, "meta.model", 512);
367
377
  if (raw.phases !== undefined) {
368
378
  if (!Array.isArray(raw.phases)) throw new Error("meta.phases must be an array");
369
379
  meta.phases = raw.phases.map((phase, i) => {
370
380
  if (!isRecord(phase) || typeof phase.title !== "string" || phase.title.length === 0) {
371
381
  throw new Error(`meta.phases[${i}].title is invalid`);
372
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
+ }
373
386
  const out: NonNullable<WorkflowMeta["phases"]>[number] = { title: phase.title.slice(0, 512) };
374
387
  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
388
  return out;
377
389
  });
378
390
  }
379
391
  return meta;
380
392
  }
381
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
+
382
400
  function parseResult(raw: unknown, label: string): CallResult {
383
401
  if (!isRecord(raw)) throw new Error(`${label} is invalid`);
384
402
  const status = raw.status;
@@ -390,10 +408,12 @@ function parseResult(raw: unknown, label: string): CallResult {
390
408
  throw new Error(`${label} compactionCount is invalid`);
391
409
  }
392
410
  const updatedAt = validTimestamp(raw.updatedAt, label);
411
+ const tierIdentity = parseCallTierIdentity(raw, label);
393
412
  const result: CallResult = {
394
413
  status,
395
414
  compactionCount,
396
415
  updatedAt,
416
+ ...tierIdentity,
397
417
  ...(raw.agentId === undefined ? {} : { agentId: boundedString(raw.agentId, `${label}.agentId`, JOURNAL_ID_LIMIT) }),
398
418
  ...(raw.attemptId === undefined
399
419
  ? {}
@@ -417,6 +437,17 @@ function parseResult(raw: unknown, label: string): CallResult {
417
437
  return result;
418
438
  }
419
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
+
420
451
  function parseOwner(raw: unknown, runId: string, nodeId: string): WorkflowOwner {
421
452
  if (!isRecord(raw) || raw.extension !== "pi-workflows") throw new Error("journal owner is invalid");
422
453
  const ownerRunId = boundedString(raw.runId, "owner.runId", JOURNAL_ID_LIMIT);
@@ -486,6 +517,7 @@ function parseRotation(raw: unknown, runId: string): RecoveryRotation {
486
517
  if (generation <= sourceGeneration) throw new Error("rotation generation must exceed its source");
487
518
  const owner = parseOwner(raw.owner, runId, nodeId);
488
519
  if (owner.attemptId !== attemptId) throw new Error("rotation owner does not match its attempt");
520
+ const tierIdentity = parseCallTierIdentity(raw, "rotation");
489
521
  return {
490
522
  nodeId,
491
523
  sourceAttemptId,
@@ -494,6 +526,7 @@ function parseRotation(raw: unknown, runId: string): RecoveryRotation {
494
526
  attemptId,
495
527
  generation,
496
528
  owner,
529
+ ...tierIdentity,
497
530
  ...(raw.supersededAgentId === undefined
498
531
  ? {}
499
532
  : { supersededAgentId: boundedString(raw.supersededAgentId, "rotation.supersededAgentId", JOURNAL_ID_LIMIT) }),
@@ -523,7 +556,9 @@ function parseRunRecovery(raw: Record<string, unknown>, run: ScriptRun, timestam
523
556
  raw.terminalResults === undefined
524
557
  ? undefined
525
558
  : (() => {
526
- 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
+ }
527
562
  return raw.terminalResults.map((terminal) => parseTerminalResult(terminal, run.runId));
528
563
  })();
529
564
  const recoveryId = boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT);
@@ -588,7 +623,6 @@ function parseAttemptRecovery(raw: Record<string, unknown>, run: ScriptRun, time
588
623
  nodeId: rotation.nodeId,
589
624
  branchGeneration: raw.branchGeneration,
590
625
  rotation,
591
- ...(raw.legacyMigration === true ? { legacyMigration: true } : {}),
592
626
  timestamp,
593
627
  };
594
628
  }
@@ -600,53 +634,20 @@ export function recoverySemanticFingerprint(event: RunRecoveryEvent | TerminalRe
600
634
  }
601
635
 
602
636
  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
- };
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"));
626
641
  }
627
642
 
628
643
  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
- };
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"));
646
647
  }
647
648
 
648
- function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean | undefined {
649
- if (raw.frozenArgsPresent === undefined) return undefined;
649
+ function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean {
650
+ if (raw.frozenArgsPresent === undefined) throw new Error("frozen args presence marker is required");
650
651
  if (typeof raw.frozenArgsPresent !== "boolean") throw new Error("frozen args presence marker must be boolean");
651
652
  const hasArgs = Object.hasOwn(raw, "args");
652
653
  if (raw.frozenArgsPresent && (!hasArgs || raw.args === undefined)) {
@@ -658,15 +659,13 @@ function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean | undefi
658
659
  return raw.frozenArgsPresent;
659
660
  }
660
661
 
661
- function parseEvent(
662
- raw: unknown,
663
- runs: Map<string, ScriptRun>,
664
- legacyArgsBackfilled: ReadonlySet<string>,
665
- ): JournalEvent {
662
+ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
666
663
  if (!isRecord(raw)) throw new Error("journal event is not an object");
667
664
  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");
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
+ );
670
669
  }
671
670
  const runId = boundedString(raw.runId, "runId", JOURNAL_ID_LIMIT);
672
671
  const timestamp = validTimestamp(raw.timestamp, "timestamp");
@@ -678,46 +677,72 @@ function parseEvent(
678
677
  const meta = parseMeta(raw.meta);
679
678
  const frozenArgsPresent = parseFrozenArgsPresence(raw);
680
679
  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;
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
+ })();
721
746
  return {
722
747
  kind: "run_created",
723
748
  schemaVersion: raw.schemaVersion as JournalSchemaVersion,
@@ -726,7 +751,8 @@ function parseEvent(
726
751
  scriptHash,
727
752
  meta,
728
753
  ...(args === undefined ? {} : { args }),
729
- ...(frozenArgsPresent === undefined ? {} : { frozenArgsPresent }),
754
+ frozenArgsPresent,
755
+ ...(shippedScript === undefined ? {} : { shippedScript }),
730
756
  ...(toolset ? { toolset } : {}),
731
757
  ...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
732
758
  ...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
@@ -742,21 +768,6 @@ function parseEvent(
742
768
 
743
769
  const run = runs.get(runId);
744
770
  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
771
  if (raw.kind === "run_revision") {
761
772
  const revision = raw.revision;
762
773
  if (typeof revision !== "number" || !Number.isInteger(revision) || revision <= (run.revision ?? 0))
@@ -819,7 +830,10 @@ function parseEvent(
819
830
  if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("workflow result schema version is unsupported");
820
831
  if (raw.owner !== undefined || raw.attemptId !== undefined)
821
832
  throw new Error("workflow result cannot carry a managed attempt");
822
- const result = parseResult(raw.result, "workflow result");
833
+ const result = mergeCallResultTierIdentity(
834
+ priorCallTierIdentity(run, nodeId),
835
+ parseResult(raw.result, "workflow result"),
836
+ );
823
837
  if (result.status !== "completed") throw new Error("workflow result must be completed");
824
838
  if (result.attemptId !== undefined) throw new Error("workflow result payload cannot carry a managed attempt");
825
839
  const generation = raw.generation;
@@ -895,6 +909,7 @@ function parseEvent(
895
909
  if (attemptId !== expectedAttemptId || owner?.attemptId !== attemptId) {
896
910
  throw new Error("call attempt does not identify its next generation");
897
911
  }
912
+ const tierIdentity = parseCallTierIdentity(raw, "call attempt");
898
913
  return {
899
914
  kind: "call_attempt",
900
915
  schemaVersion: JOURNAL_SCHEMA_VERSION,
@@ -902,6 +917,7 @@ function parseEvent(
902
917
  nodeId,
903
918
  attemptId,
904
919
  generation,
920
+ ...tierIdentity,
905
921
  timestamp,
906
922
  ...(owner ? { owner } : {}),
907
923
  };
@@ -910,12 +926,14 @@ function parseEvent(
910
926
  if (raw.kind === "call_transition") {
911
927
  if (!CALL_STATUS_VALUES.has(raw.status as CallStatus)) throw new Error("call status is unknown");
912
928
  const agentId = raw.agentId === undefined ? undefined : boundedString(raw.agentId, "agentId", JOURNAL_ID_LIMIT);
929
+ const tierIdentity = parseCallTierIdentity(raw, "call transition");
913
930
  return {
914
931
  kind: "call_transition",
915
932
  schemaVersion: JOURNAL_SCHEMA_VERSION,
916
933
  runId,
917
934
  nodeId,
918
935
  status: raw.status as CallStatus,
936
+ ...tierIdentity,
919
937
  timestamp,
920
938
  ...(agentId ? { agentId } : {}),
921
939
  ...(attemptId ? { attemptId } : {}),
@@ -930,12 +948,14 @@ function parseEvent(
930
948
  if (result.attemptId && attemptId && result.attemptId !== attemptId) {
931
949
  throw new StaleJournalGenerationError("call result payload references a superseded attempt");
932
950
  }
951
+ const normalizedResult = result.attemptId === undefined && attemptId ? { ...result, attemptId } : result;
952
+ const resultWithTierIdentity = mergeCallResultTierIdentity(priorCallTierIdentity(run, nodeId), normalizedResult);
933
953
  return {
934
954
  kind: "call_result",
935
955
  schemaVersion: JOURNAL_SCHEMA_VERSION,
936
956
  runId,
937
957
  nodeId,
938
- result: result.attemptId === undefined && attemptId ? { ...result, attemptId } : result,
958
+ result: resultWithTierIdentity,
939
959
  ...(raw.callHash === undefined ? {} : { callHash: boundedString(raw.callHash, "callHash", 128) }),
940
960
  ...(raw.storeDelta === undefined
941
961
  ? {}
@@ -969,7 +989,8 @@ export function replayJournal(
969
989
  const runs = new Map<string, ScriptRun>();
970
990
  const quarantined = new Set<string>();
971
991
  const removed = new Set<string>();
972
- const legacyArgsBackfilled = new Set<string>();
992
+ /** Parsed creation facts are retained separately from mutable run state for duplicate checks. */
993
+ const creationFingerprints = new Map<string, string>();
973
994
  /** Recovery identities are scoped to one replayed run and bounded explicitly. */
974
995
  const seenRecoveries = new Map<string, Map<string, string>>();
975
996
  let reported = false;
@@ -984,18 +1005,28 @@ export function replayJournal(
984
1005
  const rawRunId = isRecord(raw) && typeof raw.runId === "string" ? raw.runId : undefined;
985
1006
  if (rawRunId && quarantined.has(rawRunId)) continue;
986
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;
987
1015
  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;
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;
999
1030
  throw new Error("conflicting duplicate workflow run_created event");
1000
1031
  }
1001
1032
  if (
@@ -1008,7 +1039,12 @@ export function replayJournal(
1008
1039
  throw new Error(`recoveryId ${raw.recoveryId} is already owned by workflow run ${seenRunId}`);
1009
1040
  }
1010
1041
  }
1011
- 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
+ ) {
1012
1048
  const run = runs.get(rawRunId);
1013
1049
  const seen = seenRecoveries.get(rawRunId);
1014
1050
  if (run && seen) {
@@ -1027,11 +1063,13 @@ export function replayJournal(
1027
1063
  }
1028
1064
  }
1029
1065
 
1030
- const event = parseEvent(raw, runs, legacyArgsBackfilled);
1066
+ const event = parseEvent(raw, runs);
1031
1067
  if (quarantined.has(event.runId)) continue;
1032
1068
  if (event.kind === "run_created") {
1033
1069
  applyJournalEvent(runs, event);
1070
+ creationFingerprints.set(event.runId, JSON.stringify(event));
1034
1071
  seenRecoveries.set(event.runId, new Map());
1072
+ accepted = event;
1035
1073
  continue;
1036
1074
  }
1037
1075
  if (event.kind === "run_recovery" || event.kind === "terminal_recovery") {
@@ -1050,23 +1088,25 @@ export function replayJournal(
1050
1088
  applyJournalEvent(runs, event);
1051
1089
  seen.set(event.recoveryId, recoverySemanticFingerprint(event));
1052
1090
  seenRecoveries.set(event.runId, seen);
1091
+ accepted = event;
1053
1092
  continue;
1054
1093
  }
1055
1094
  applyJournalEvent(runs, event);
1056
- if (event.kind === "run_args") legacyArgsBackfilled.add(event.runId);
1095
+ accepted = event;
1057
1096
  if (event.kind === "run_removed") {
1058
1097
  removed.add(event.runId);
1059
- legacyArgsBackfilled.delete(event.runId);
1060
1098
  }
1061
1099
  } catch (error: unknown) {
1062
1100
  if (error instanceof StaleJournalGenerationError) continue;
1063
1101
  if (rawRunId) {
1064
1102
  quarantined.add(rawRunId);
1065
1103
  runs.delete(rawRunId);
1104
+ creationFingerprints.delete(rawRunId);
1066
1105
  seenRecoveries.delete(rawRunId);
1067
- legacyArgsBackfilled.delete(rawRunId);
1068
1106
  }
1069
1107
  report(error instanceof Error ? error.message : String(error));
1108
+ } finally {
1109
+ if (accepted) options.onAccepted?.(accepted);
1070
1110
  }
1071
1111
  }
1072
1112
  for (const [runId, run] of runs) {
@@ -1075,6 +1115,7 @@ export function replayJournal(
1075
1115
  } catch (error: unknown) {
1076
1116
  runs.delete(runId);
1077
1117
  quarantined.add(runId);
1118
+ creationFingerprints.delete(runId);
1078
1119
  seenRecoveries.delete(runId);
1079
1120
  report(`${runId}: ${error instanceof Error ? error.message : String(error)}`);
1080
1121
  }
@@ -1088,15 +1129,19 @@ function applyRecoveryRotation(run: ScriptRun, rotation: RecoveryRotation): void
1088
1129
  run.attemptIds[rotation.nodeId] = rotation.attemptId;
1089
1130
  delete run.agentIds[rotation.nodeId];
1090
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 };
1091
1134
  run.compactions[rotation.nodeId] = 0;
1092
1135
  run.callStatus[rotation.nodeId] = "running";
1093
1136
  }
1094
1137
 
1095
1138
  function applyRecoveryTerminalResult(run: ScriptRun, terminal: RecoveryTerminalResult): void {
1096
1139
  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;
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;
1100
1145
  }
1101
1146
 
1102
1147
  export function applyRecoveryTerminalResults(run: ScriptRun, terminalResults: readonly RecoveryTerminalResult[]): void {
@@ -1145,8 +1190,9 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1145
1190
  scriptHash: event.scriptHash,
1146
1191
  meta: event.meta,
1147
1192
  ...(event.args === undefined ? {} : { args: structuredClone(event.args) }),
1148
- ...(event.frozenArgsPresent === undefined ? {} : { frozenArgsPresent: event.frozenArgsPresent }),
1193
+ frozenArgsPresent: event.frozenArgsPresent,
1149
1194
  revision: 0,
1195
+ ...(event.shippedScript === undefined ? {} : { shippedScript: event.shippedScript }),
1150
1196
  ...(event.toolset ? { toolset: event.toolset } : {}),
1151
1197
  ...(event.frozenMaxAgents !== undefined ? { frozenMaxAgents: event.frozenMaxAgents } : {}),
1152
1198
  ...(event.frozenConcurrency !== undefined ? { frozenConcurrency: event.frozenConcurrency } : {}),
@@ -1161,6 +1207,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1161
1207
  attemptIds,
1162
1208
  attemptTracking: event.attemptIds !== undefined,
1163
1209
  callResults: {},
1210
+ callTiers: {},
1164
1211
  workflowResultGenerations: {},
1165
1212
  compactions: {},
1166
1213
  startedAt: event.timestamp,
@@ -1177,9 +1224,6 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1177
1224
  }
1178
1225
  run.updatedAt = event.timestamp;
1179
1226
  switch (event.kind) {
1180
- case "run_args":
1181
- run.args = structuredClone(event.args);
1182
- break;
1183
1227
  case "run_revision":
1184
1228
  run.revision = event.revision;
1185
1229
  run.script = event.script;
@@ -1205,6 +1249,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1205
1249
  run.attemptIds[event.nodeId] = event.attemptId;
1206
1250
  delete run.agentIds[event.nodeId];
1207
1251
  delete run.callResults[event.nodeId];
1252
+ if (event.tier !== undefined) run.callTiers[event.nodeId] = { tier: event.tier };
1208
1253
  run.callStatus[event.nodeId] = "running";
1209
1254
  run.compactions[event.nodeId] = 0;
1210
1255
  break;
@@ -1212,30 +1257,42 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1212
1257
  if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
1213
1258
  throw new Error("call transition references a superseded attempt");
1214
1259
  }
1215
- 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
+ }
1216
1270
  if (event.agentId) run.agentIds[event.nodeId] = event.agentId;
1217
1271
  break;
1218
- case "workflow_result":
1272
+ case "workflow_result": {
1219
1273
  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;
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;
1223
1279
  break;
1224
- case "call_result":
1280
+ }
1281
+ case "call_result": {
1225
1282
  if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
1226
1283
  throw new Error("call result references a superseded attempt");
1227
1284
  }
1228
- if (
1229
- run.callResults[event.nodeId] &&
1230
- JSON.stringify(run.callResults[event.nodeId]) !== JSON.stringify(event.result)
1231
- ) {
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)) {
1232
1287
  throw new Error("contradictory terminal call result");
1233
1288
  }
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;
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;
1238
1294
  break;
1295
+ }
1239
1296
  }
1240
1297
  }
1241
1298
 
@@ -1310,9 +1367,9 @@ export function snapshotRun(run: ScriptRun): ScriptRun {
1310
1367
  * The runtime keys its cache by `${runId}:${callIndex}` (the same namespacing
1311
1368
  * the store deltas use), so a nested workflow() call with its own runId can
1312
1369
  * 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.
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.
1316
1373
  */
1317
1374
  export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
1318
1375
  string,
@@ -1321,88 +1378,70 @@ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
1321
1378
  runId?: string;
1322
1379
  hash: string;
1323
1380
  result: unknown;
1381
+ tier?: string;
1324
1382
  storeDelta?: Record<string, unknown>;
1325
1383
  generation?: number;
1326
1384
  agentCount?: number;
1327
1385
  }
1328
1386
  > {
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");
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;
1401
1433
  }
1402
- continue;
1403
1434
  }
1404
- }
1405
- journal.set(key, candidate);
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);
1406
1445
  }
1407
1446
  return journal;
1408
1447
  }