@signalridge/pi-workflows 1.7.5 → 1.9.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.
@@ -0,0 +1,544 @@
1
+ /** Read-only, bounded presentation facts. No prompts, args, histories, or full results. */
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import type { ScriptRun } from "./journal.js";
4
+ import {
5
+ type AgentRunOptions,
6
+ MAX_RUNTIME_IDENTITY_LENGTH,
7
+ type WorkflowRunOptions,
8
+ type WorkflowRuntimeEvent,
9
+ } from "./runtime.js";
10
+
11
+ export const INSPECTION_LIMITS = {
12
+ calls: 2_048,
13
+ phases: 256,
14
+ tasks: 256,
15
+ events: 128,
16
+ text: 512,
17
+ preview: 300,
18
+ } as const;
19
+ export type InspectionStatus =
20
+ | "unknown"
21
+ | "pending"
22
+ | "queued"
23
+ | "running"
24
+ | "completed"
25
+ | "failed"
26
+ | "stopped"
27
+ | "interrupted";
28
+ export interface WorkflowInspectionPhase {
29
+ readonly id: string;
30
+ readonly frameId: string;
31
+ /** Opaque frame-scoped hash of the full, untruncated title. */
32
+ readonly phaseKey?: string;
33
+ readonly title: string;
34
+ readonly detail?: string;
35
+ readonly declared: boolean;
36
+ /** Only an explicit phase event proves entry; attribution and metadata do not. */
37
+ readonly entered: boolean;
38
+ }
39
+ export interface WorkflowInspectionCall {
40
+ readonly id: string;
41
+ readonly frameId: string;
42
+ readonly index: number;
43
+ readonly kind: "agent" | "workflow" | "checkpoint" | "unknown";
44
+ readonly label?: string;
45
+ readonly phase?: string;
46
+ /** Optional for legacy adapters; never infer this from a display label. */
47
+ readonly phaseKey?: string;
48
+ readonly agentType?: string;
49
+ readonly tier?: string;
50
+ readonly graphId?: string;
51
+ readonly taskKey?: string;
52
+ readonly taskId?: string;
53
+ readonly status: InspectionStatus;
54
+ /** Physical terminal status can differ from the script's logical outcome. */
55
+ readonly physicalStatus?: InspectionStatus;
56
+ readonly physicalDispatches?: number;
57
+ readonly replayed?: boolean;
58
+ readonly replayedAgentCount?: number;
59
+ readonly childFrameId?: string;
60
+ readonly observed: boolean;
61
+ readonly journalOnly: boolean;
62
+ readonly agentId?: string;
63
+ readonly attemptId?: string;
64
+ readonly outputFile?: string;
65
+ readonly tokenCount?: number;
66
+ readonly compactionCount?: number;
67
+ readonly resultPreview?: string;
68
+ readonly errorPreview?: string;
69
+ }
70
+ export interface WorkflowInspectionTask {
71
+ readonly id: string;
72
+ readonly frameId: string;
73
+ readonly graphId: string;
74
+ readonly taskId: string;
75
+ readonly description?: string;
76
+ readonly phase?: string;
77
+ readonly attempt: number;
78
+ readonly errorPreview?: string;
79
+ readonly status: InspectionStatus | "skipped";
80
+ }
81
+ export interface WorkflowInspectionEvent {
82
+ readonly id: string;
83
+ readonly type: WorkflowRuntimeEvent["type"] | "log";
84
+ readonly frameId: string;
85
+ readonly stage?: string;
86
+ readonly childFrameId?: string;
87
+ readonly title?: string;
88
+ readonly graphId?: string;
89
+ readonly taskKey?: string;
90
+ readonly helperId?: string;
91
+ readonly helper?: string;
92
+ readonly outcome?: string;
93
+ readonly accepted?: boolean;
94
+ readonly attempt?: number;
95
+ readonly message?: string;
96
+ }
97
+ export interface WorkflowInspection {
98
+ readonly runId: string;
99
+ /** Opaque engine-state/execution identity; never use runId alone to own a UI mount. */
100
+ readonly executionId: string;
101
+ readonly executionGeneration: number;
102
+ readonly revision: number;
103
+ readonly name: string;
104
+ readonly status: ScriptRun["status"];
105
+ readonly observationSource: "execution" | "journal-only";
106
+ readonly restored: boolean;
107
+ readonly currentPhase?: string;
108
+ readonly currentPhaseKey?: string;
109
+ readonly phases: readonly WorkflowInspectionPhase[];
110
+ readonly calls: readonly WorkflowInspectionCall[];
111
+ readonly tasks: readonly WorkflowInspectionTask[];
112
+ readonly events: readonly WorkflowInspectionEvent[];
113
+ /** Counters cover observed callbacks, not anonymous historical journal records. */
114
+ readonly logicalCalls: number;
115
+ readonly replayedCalls: number;
116
+ readonly physicalDispatches: number;
117
+ readonly truncated: boolean;
118
+ readonly resultPreview?: string;
119
+ readonly errorPreview?: string;
120
+ }
121
+
122
+ export type WorkflowInspectionHeader = Pick<
123
+ WorkflowInspection,
124
+ "currentPhase" | "currentPhaseKey" | "restored" | "revision" | "executionId" | "executionGeneration"
125
+ >;
126
+
127
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
128
+ type StartEvent = Parameters<NonNullable<WorkflowRunOptions["onAgentStart"]>>[0];
129
+ type EndEvent = Parameters<NonNullable<WorkflowRunOptions["onAgentEnd"]>>[0];
130
+ const text = (value: unknown, limit: number = INSPECTION_LIMITS.text): string | undefined =>
131
+ typeof value === "string" ? value.slice(0, limit) : undefined;
132
+ function identity(value: unknown): string | undefined {
133
+ if (value === undefined) return undefined;
134
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_RUNTIME_IDENTITY_LENGTH)
135
+ throw new Error(
136
+ `Inspection identity must be a non-empty string of at most ${MAX_RUNTIME_IDENTITY_LENGTH} characters`,
137
+ );
138
+ return value;
139
+ }
140
+ function preview(value: unknown): string | undefined {
141
+ if (value === undefined) return undefined;
142
+ if (typeof value === "string") return value.slice(0, INSPECTION_LIMITS.preview);
143
+ try {
144
+ return JSON.stringify(value)?.slice(0, INSPECTION_LIMITS.preview);
145
+ } catch {
146
+ return "[unavailable]";
147
+ }
148
+ }
149
+ const phaseKey = (frameId: string, title: string): string =>
150
+ createHash("sha256")
151
+ .update(JSON.stringify([frameId, title]))
152
+ .digest("hex");
153
+ function status(value: unknown): InspectionStatus {
154
+ return value === "pending" ||
155
+ value === "queued" ||
156
+ value === "running" ||
157
+ value === "completed" ||
158
+ value === "failed" ||
159
+ value === "stopped" ||
160
+ value === "interrupted"
161
+ ? value
162
+ : "unknown";
163
+ }
164
+
165
+ /** Engine-owned projection; public consumers receive detached snapshots only. */
166
+ export class WorkflowPresentation {
167
+ private executionId = randomUUID();
168
+ private revision = 0;
169
+ private observed = false;
170
+ private truncated = false;
171
+ private currentPhase?: string;
172
+ private currentPhaseKey?: string;
173
+ private readonly calls = new Map<string, Mutable<WorkflowInspectionCall>>();
174
+ private readonly phases = new Map<string, Mutable<WorkflowInspectionPhase>>();
175
+ private readonly tasks = new Map<string, Mutable<WorkflowInspectionTask>>();
176
+ private readonly events: WorkflowInspectionEvent[] = [];
177
+ private logicalCalls = 0;
178
+ private replayedCalls = 0;
179
+ private physicalDispatches = 0;
180
+ constructor(
181
+ private readonly rootFrameId: string,
182
+ private readonly restored = false,
183
+ ) {
184
+ identity(rootFrameId);
185
+ }
186
+
187
+ reset(observed = true): void {
188
+ this.executionId = randomUUID();
189
+ this.observed = observed;
190
+ this.currentPhase = undefined;
191
+ this.currentPhaseKey = undefined;
192
+ this.calls.clear();
193
+ this.phases.clear();
194
+ this.tasks.clear();
195
+ this.events.length = 0;
196
+ this.logicalCalls = this.replayedCalls = this.physicalDispatches = 0;
197
+ this.truncated = false;
198
+ this.revision++;
199
+ }
200
+
201
+ changed(): void {
202
+ this.revision++;
203
+ }
204
+
205
+ private call(id: string, frameId: string, index: number): Mutable<WorkflowInspectionCall> {
206
+ identity(id);
207
+ identity(frameId);
208
+ const existing = this.calls.get(id);
209
+ if (existing) return existing;
210
+ this.logicalCalls++;
211
+ if (this.calls.size >= INSPECTION_LIMITS.calls) {
212
+ this.calls.delete(this.calls.keys().next().value ?? "");
213
+ this.truncated = true;
214
+ }
215
+ const call: Mutable<WorkflowInspectionCall> = {
216
+ id,
217
+ frameId,
218
+ index,
219
+ kind: "unknown",
220
+ status: "unknown",
221
+ observed: true,
222
+ journalOnly: false,
223
+ };
224
+ this.calls.set(id, call);
225
+ return call;
226
+ }
227
+
228
+ agentStart(event: StartEvent): void {
229
+ for (const key of [event.id, event.frameId, event.graphId, event.taskKey, event.taskId, event.phaseKey])
230
+ identity(key);
231
+ const frameId = event.frameId ?? event.id.slice(0, event.id.lastIndexOf(":"));
232
+ const index = event.callIndex ?? Number(event.id.slice(event.id.lastIndexOf(":") + 1));
233
+ const call = this.call(event.id, frameId, index);
234
+ Object.assign(call, {
235
+ kind: "agent",
236
+ label: text(event.label),
237
+ phase: text(event.phase),
238
+ phaseKey: identity(event.phaseKey),
239
+ agentType: text(event.agentType),
240
+ tier: text(event.tier),
241
+ graphId: identity(event.graphId),
242
+ taskKey: identity(event.taskKey),
243
+ taskId: identity(event.taskId),
244
+ status: "running",
245
+ replayed: event.replayed === true,
246
+ physicalDispatches: call.physicalDispatches ?? 0,
247
+ });
248
+ if (event.replayed) this.replayedCalls++;
249
+ this.revision++;
250
+ }
251
+
252
+ agentEnd(event: EndEvent): void {
253
+ identity(event.id);
254
+ const call = this.calls.get(event.id);
255
+ if (!call) return;
256
+ call.status = event.error ? "failed" : "completed";
257
+ call.tier = text(event.tier) ?? call.tier;
258
+ call.resultPreview = preview(event.result);
259
+ call.errorPreview = text(event.error, INSPECTION_LIMITS.preview);
260
+ call.tokenCount = event.tokens;
261
+ this.revision++;
262
+ }
263
+
264
+ dispatch(options: AgentRunOptions, attemptId: string): void {
265
+ identity(attemptId);
266
+ const frameId = options.runId ?? this.rootFrameId;
267
+ const index = options.callIndex ?? 0;
268
+ const call = this.call(`${frameId}:${index}`, frameId, index);
269
+ call.physicalDispatches = (call.physicalDispatches ?? 0) + 1;
270
+ call.physicalStatus = "pending";
271
+ call.attemptId = identity(attemptId);
272
+ // An earlier retry's terminal identity/preview is not this new attempt.
273
+ call.agentId = call.resultPreview = call.errorPreview = call.outputFile = undefined;
274
+ call.tokenCount = call.compactionCount = undefined;
275
+ this.physicalDispatches++;
276
+ this.revision++;
277
+ }
278
+
279
+ managed(
280
+ nodeId: string,
281
+ fact: {
282
+ status?: string;
283
+ agentId?: string;
284
+ tier?: string;
285
+ outputFile?: string;
286
+ tokenCount?: number;
287
+ compactionCount?: number;
288
+ },
289
+ ): void {
290
+ identity(nodeId);
291
+ identity(fact.agentId);
292
+ const id = nodeId.includes(":") ? nodeId : `${this.rootFrameId}:${nodeId}`;
293
+ const call = this.calls.get(id);
294
+ if (!call) return;
295
+ if (fact.status !== undefined) call.physicalStatus = status(fact.status);
296
+ if (fact.agentId !== undefined) call.agentId = identity(fact.agentId);
297
+ if (fact.tier !== undefined) call.tier = text(fact.tier);
298
+ if (fact.outputFile !== undefined) call.outputFile = text(fact.outputFile);
299
+ if (fact.tokenCount !== undefined) call.tokenCount = fact.tokenCount;
300
+ if (fact.compactionCount !== undefined) call.compactionCount = fact.compactionCount;
301
+ this.revision++;
302
+ }
303
+
304
+ journalResult(frameId: string, index: number, result: unknown): void {
305
+ identity(frameId);
306
+ const call = this.calls.get(`${frameId}:${index}`);
307
+ if (!call) return;
308
+ call.resultPreview = preview(result);
309
+ this.revision++;
310
+ }
311
+
312
+ log(message: string, frameId = this.rootFrameId): void {
313
+ identity(frameId);
314
+ this.record({ type: "log", frameId, message: text(message) });
315
+ }
316
+
317
+ runtime(event: WorkflowRuntimeEvent): void {
318
+ const rawFrameId = typeof event.frameId === "string" ? event.frameId : this.rootFrameId;
319
+ const frameId = identity(rawFrameId) ?? this.rootFrameId;
320
+ // Validate before retaining state: display limits must never alias keys.
321
+ for (const key of ["runId", "childFrameId", "graphId", "taskKey", "taskId", "helperId", "phaseKey"])
322
+ identity(event[key]);
323
+ if (event.type === "phase" && typeof event.title === "string") {
324
+ const title = text(event.title) ?? "";
325
+ // Hash exact titles so bounded display labels cannot alias identities or retain unbounded input.
326
+ const key = phaseKey(rawFrameId, event.title);
327
+ if (rawFrameId === this.rootFrameId) {
328
+ this.currentPhase = title;
329
+ this.currentPhaseKey = key;
330
+ }
331
+ if (!this.phases.has(key)) {
332
+ if (this.phases.size >= INSPECTION_LIMITS.phases) {
333
+ this.phases.delete(this.phases.keys().next().value ?? "");
334
+ this.truncated = true;
335
+ }
336
+ this.phases.set(key, {
337
+ id: `${frameId}:phase-${this.revision}`,
338
+ frameId,
339
+ phaseKey: key,
340
+ title,
341
+ declared: false,
342
+ entered: true,
343
+ });
344
+ }
345
+ }
346
+ if (event.type === "task" && typeof event.taskKey === "string" && typeof event.taskId === "string") {
347
+ const id = identity(event.taskKey) ?? "";
348
+ if (!this.tasks.has(id) && this.tasks.size >= INSPECTION_LIMITS.tasks) {
349
+ this.tasks.delete(this.tasks.keys().next().value ?? "");
350
+ this.truncated = true;
351
+ }
352
+ this.tasks.set(id, {
353
+ id,
354
+ frameId,
355
+ graphId: identity(event.graphId) ?? "",
356
+ taskId: identity(event.taskId) ?? "",
357
+ description: text(event.description),
358
+ phase: text(event.phase),
359
+ attempt: typeof event.attempt === "number" ? event.attempt : 0,
360
+ errorPreview: text(event.error, INSPECTION_LIMITS.preview),
361
+ status: event.stage === "skip" ? "skipped" : event.stage === "end" ? status(event.status) : "running",
362
+ });
363
+ }
364
+ if (event.type === "control-attempt" && event.helper === "checkpoint" && typeof event.callIndex === "number") {
365
+ const call = this.call(`${frameId}:${event.callIndex}`, frameId, event.callIndex);
366
+ call.kind = "checkpoint";
367
+ call.label = "checkpoint";
368
+ call.phase = text(event.phase);
369
+ call.phaseKey = identity(event.phaseKey);
370
+ call.graphId = identity(event.graphId);
371
+ call.taskKey = identity(event.taskKey);
372
+ call.taskId = identity(event.taskId);
373
+ call.physicalDispatches = 0;
374
+ call.replayed = event.stage === "replay";
375
+ if (call.replayed) this.replayedCalls++;
376
+ call.status =
377
+ event.stage === "start"
378
+ ? "running"
379
+ : event.outcome === "cancelled"
380
+ ? "interrupted"
381
+ : event.outcome === "failed"
382
+ ? "failed"
383
+ : "completed";
384
+ }
385
+ if (event.type === "workflow" && typeof event.callIndex === "number") {
386
+ const call = this.call(`${frameId}:${event.callIndex}`, frameId, event.callIndex);
387
+ call.kind = "workflow";
388
+ call.childFrameId = identity(event.childFrameId);
389
+ call.phase = text(event.phase);
390
+ call.phaseKey = identity(event.phaseKey);
391
+ call.graphId = identity(event.graphId);
392
+ call.taskKey = identity(event.taskKey);
393
+ call.taskId = identity(event.taskId);
394
+ // Only the resolver can prove a saved name. Unknown/legacy sources fail closed.
395
+ call.label = event.sourceKind === "saved" ? (text(event.name) ?? "nested workflow") : "nested workflow";
396
+ call.replayed = event.stage === "replay";
397
+ call.replayedAgentCount = typeof event.replayedAgentCount === "number" ? event.replayedAgentCount : undefined;
398
+ if (event.stage === "replay") this.replayedCalls++;
399
+ call.status =
400
+ event.stage === "start"
401
+ ? "running"
402
+ : event.outcome === "cancelled"
403
+ ? "interrupted"
404
+ : event.outcome === "failed"
405
+ ? "failed"
406
+ : "completed";
407
+ }
408
+ this.record({
409
+ type: event.type,
410
+ frameId,
411
+ stage: text(event.stage),
412
+ childFrameId: identity(event.childFrameId),
413
+ title: text(event.title),
414
+ graphId: identity(event.graphId),
415
+ taskKey: identity(event.taskKey),
416
+ helperId: identity(event.helperId),
417
+ helper: text(event.helper),
418
+ outcome: text(event.outcome),
419
+ accepted: typeof event.accepted === "boolean" ? event.accepted : undefined,
420
+ attempt: typeof event.attempt === "number" ? event.attempt : undefined,
421
+ });
422
+ }
423
+
424
+ private record(event: Omit<WorkflowInspectionEvent, "id">): void {
425
+ this.events.push({ ...event, id: `${this.executionId}:event-${++this.revision}` });
426
+ if (this.events.length > INSPECTION_LIMITS.events) {
427
+ this.events.shift();
428
+ this.truncated = true;
429
+ }
430
+ }
431
+
432
+ settle(runStatus: ScriptRun["status"]): void {
433
+ // Stop owns its terminal barrier; draining execution alone is not settlement.
434
+ if (runStatus === "stopping") return;
435
+ for (const call of this.calls.values()) {
436
+ if (call.status === "running" || call.status === "pending" || call.status === "queued") {
437
+ // A final workflow outcome does not prove unpaired calls succeeded.
438
+ call.status = runStatus === "stopped" ? "stopped" : "interrupted";
439
+ }
440
+ }
441
+ this.revision++;
442
+ }
443
+
444
+ header(executionGeneration: number): WorkflowInspectionHeader {
445
+ return {
446
+ currentPhase: this.currentPhase,
447
+ currentPhaseKey: this.currentPhaseKey,
448
+ restored: this.restored && !this.observed,
449
+ revision: this.revision,
450
+ executionId: this.executionId,
451
+ executionGeneration,
452
+ };
453
+ }
454
+
455
+ snapshot(run: ScriptRun, executionGeneration: number): WorkflowInspection {
456
+ identity(run.runId);
457
+ const phases: WorkflowInspectionPhase[] = [];
458
+ const declaredKeys = new Set<string>();
459
+ for (const [index, phase] of (run.meta.phases ?? []).slice(0, INSPECTION_LIMITS.phases).entries()) {
460
+ const key = phaseKey(this.rootFrameId, phase.title);
461
+ // Repeated literal declarations do not invent additional progress units.
462
+ if (declaredKeys.has(key)) continue;
463
+ declaredKeys.add(key);
464
+ phases.push({
465
+ id: `${this.rootFrameId}:declared-${index}`,
466
+ frameId: this.rootFrameId,
467
+ phaseKey: key,
468
+ title: text(phase.title) ?? "",
469
+ detail: text(phase.detail),
470
+ declared: true,
471
+ entered: this.phases.has(key),
472
+ });
473
+ }
474
+ for (const [key, phase] of this.phases) {
475
+ if (!declaredKeys.has(key)) phases.push({ ...phase });
476
+ }
477
+ const calls = new Map<string, WorkflowInspectionCall>();
478
+ for (const [nodeId, durableStatus] of Object.entries(run.callStatus).slice(0, INSPECTION_LIMITS.calls)) {
479
+ const result = run.callResults[nodeId];
480
+ calls.set(`${this.rootFrameId}:${nodeId}`, {
481
+ id: `${this.rootFrameId}:${nodeId}`,
482
+ frameId: this.rootFrameId,
483
+ index: Number(nodeId),
484
+ kind: "unknown",
485
+ status: status(durableStatus),
486
+ observed: false,
487
+ journalOnly: true,
488
+ tier: text(result?.tier ?? run.callTiers[nodeId]?.tier),
489
+ // A terminal result without managed identity may be local work. Never attach a prior child's id.
490
+ agentId: identity(result === undefined ? run.agentIds[nodeId] : result.agentId),
491
+ attemptId: identity(run.attemptIds[nodeId]),
492
+ outputFile: text(result?.outputFile),
493
+ tokenCount: result?.tokenCount,
494
+ compactionCount: result?.compactionCount ?? run.compactions[nodeId],
495
+ resultPreview: preview(result?.result),
496
+ errorPreview: text(result?.error, INSPECTION_LIMITS.preview),
497
+ });
498
+ }
499
+ for (const [id, observed] of this.calls) {
500
+ const durable = calls.get(id);
501
+ // Replayed calls may reference their retained historical child/results, never an invented live dispatch.
502
+ const replayFacts =
503
+ observed.replayed && durable?.status === "completed"
504
+ ? {
505
+ agentId: durable.agentId,
506
+ attemptId: durable.attemptId,
507
+ outputFile: durable.outputFile,
508
+ compactionCount: durable.compactionCount,
509
+ resultPreview: durable.resultPreview,
510
+ }
511
+ : {};
512
+ const merged = { ...replayFacts, ...observed };
513
+ if (observed.replayed && observed.resultPreview === undefined) merged.resultPreview = replayFacts.resultPreview;
514
+ calls.set(id, merged);
515
+ }
516
+ const boundedCalls = [...calls.values()].slice(-INSPECTION_LIMITS.calls);
517
+ return structuredClone({
518
+ runId: run.runId,
519
+ executionId: this.executionId,
520
+ executionGeneration,
521
+ revision: this.revision,
522
+ name: text(run.meta.name) ?? "",
523
+ status: run.status,
524
+ observationSource: this.observed ? "execution" : "journal-only",
525
+ restored: this.restored && !this.observed,
526
+ currentPhase: this.currentPhase,
527
+ currentPhaseKey: this.currentPhaseKey,
528
+ phases: phases.slice(0, INSPECTION_LIMITS.phases),
529
+ calls: boundedCalls,
530
+ tasks: [...this.tasks.values()],
531
+ events: this.events,
532
+ logicalCalls: this.logicalCalls,
533
+ replayedCalls: this.replayedCalls,
534
+ physicalDispatches: this.physicalDispatches,
535
+ truncated:
536
+ this.truncated ||
537
+ calls.size > INSPECTION_LIMITS.calls ||
538
+ phases.length > INSPECTION_LIMITS.phases ||
539
+ (run.meta.phases?.length ?? 0) > INSPECTION_LIMITS.phases,
540
+ resultPreview: preview(run.finalResult),
541
+ errorPreview: text(run.error, INSPECTION_LIMITS.preview),
542
+ });
543
+ }
544
+ }
package/src/journal.ts CHANGED
@@ -154,7 +154,7 @@ export interface ScriptRun {
154
154
  callResults: Record<string, CallResult>;
155
155
  /** Call index → resolved tier identity for live and stopped calls. */
156
156
  callTiers: Record<string, CallTierIdentity>;
157
- /** Call index → latest accepted nested workflow result generation. */
157
+ /** Monotonic nested-result watermarks; retained even when a managed call invalidates replay. */
158
158
  workflowResultGenerations: Record<string, number>;
159
159
  compactions: Record<string, number>;
160
160
  startedAt: number;
@@ -537,6 +537,8 @@ function parseTerminalResult(raw: unknown, runId: string): RecoveryTerminalResul
537
537
  }
538
538
 
539
539
  function parseRunRecovery(raw: Record<string, unknown>, run: ScriptRun, timestamp: number): RunRecoveryEvent {
540
+ if (run.status === "completed" || run.nonResumable || run.terminalIntent === "stop")
541
+ throw new Error("cannot recover a completed or non-resumable workflow run");
540
542
  if (raw.status !== "interrupted") throw new Error("run recovery status is invalid");
541
543
  if (typeof raw.branchGeneration !== "number" || !Number.isInteger(raw.branchGeneration) || raw.branchGeneration < 0) {
542
544
  throw new Error("run recovery branchGeneration is invalid");
@@ -1140,6 +1142,8 @@ export function applyRecoveryEvent(run: ScriptRun, event: RunRecoveryEvent | Att
1140
1142
  if (event.kind === "run_recovery") {
1141
1143
  run.status = "interrupted";
1142
1144
  run.terminalIntent = undefined;
1145
+ run.error = undefined;
1146
+ run.finalResult = undefined;
1143
1147
  for (const rotation of event.rotations) applyRecoveryRotation(run, rotation);
1144
1148
  applyRecoveryTerminalResults(run, event.terminalResults ?? []);
1145
1149
  } else {
@@ -1218,6 +1222,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1218
1222
  break;
1219
1223
  case "workflow_transition":
1220
1224
  run.status = event.status;
1225
+ if (event.status === "running") run.error = undefined;
1221
1226
  if (event.error !== undefined) run.error = event.error;
1222
1227
  if (event.finalResult !== undefined) run.finalResult = structuredClone(event.finalResult);
1223
1228
  if (event.terminalIntent !== undefined) run.terminalIntent = event.terminalIntent;
@@ -1353,9 +1358,10 @@ export function snapshotRun(run: ScriptRun): ScriptRun {
1353
1358
  * The runtime keys its cache by `${runId}:${callIndex}` (the same namespacing
1354
1359
  * the store deltas use), so a nested workflow() call with its own runId can
1355
1360
  * never replay the parent's entry. Every `call_result` or `workflow_result`
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.
1361
+ * event that carries a `callHash` is a replayable candidate only while it is
1362
+ * the aggregate's final completed result. Entries from another schema or
1363
+ * without a hash are not replayable, but accepted lifecycle history can still
1364
+ * supersede an older candidate.
1359
1365
  */
1360
1366
  export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
1361
1367
  string,
@@ -1381,7 +1387,7 @@ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
1381
1387
  agentCount?: number;
1382
1388
  };
1383
1389
  const journal = new Map<string, ResumeEntry>();
1384
- const journalRunIds = new Map<string, string>();
1390
+ const journalSources = new Map<string, { runId: string; nodeId: string; result: CallResult }>();
1385
1391
 
1386
1392
  // Reuse the authoritative replay validator so stale call results from an
1387
1393
  // older attempt, terminal-after-lifecycle facts, and quarantined runs never
@@ -1404,6 +1410,10 @@ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
1404
1410
  : {}),
1405
1411
  };
1406
1412
  const key = `${event.runId}:${event.nodeId}`;
1413
+ // Parsing resolves the result's tier before application, so the
1414
+ // aggregate retains this exact accepted result object. Record even an
1415
+ // identical nested retry: it becomes the aggregate's latest identity.
1416
+ journalSources.set(key, { runId: event.runId, nodeId: event.nodeId, result: event.result });
1407
1417
  const existing = journal.get(key);
1408
1418
  if (candidate.generation !== undefined && existing?.generation !== undefined) {
1409
1419
  if (candidate.generation < existing.generation) return;
@@ -1419,19 +1429,36 @@ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
1419
1429
  }
1420
1430
  }
1421
1431
  journal.set(key, candidate);
1422
- journalRunIds.set(key, event.runId);
1423
1432
  },
1424
1433
  });
1425
1434
 
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);
1435
+ // Reconcile with authoritative final state, not a second event acceptance
1436
+ // parser. Generation rotations clear results; non-replayable replacements
1437
+ // (including recovery terminal results) replace their identity; removal and
1438
+ // quarantine discard the run. Rejected stale events change none of these.
1439
+ for (const [key, source] of journalSources) {
1440
+ const run = replayedRuns.get(source.runId);
1441
+ if (run?.callStatus[source.nodeId] !== "completed" || run.callResults[source.nodeId] !== source.result) {
1442
+ journal.delete(key);
1443
+ }
1431
1444
  }
1432
1445
  return journal;
1433
1446
  }
1434
1447
 
1448
+ /**
1449
+ * Copy accepted aggregate watermarks for WorkflowRunOptions.resumeWorkflowGenerations.
1450
+ * Not a result cache: kind changes, rotations and failed results must not erase
1451
+ * an earlier nested generation or make its old value eligible for replay.
1452
+ * Supply a run returned by replayJournal (or the engine's accepted live aggregate).
1453
+ */
1454
+ export function buildWorkflowGenerations(
1455
+ run: Pick<ScriptRun, "workflowResultGenerations">,
1456
+ ): ReadonlyMap<number, number> {
1457
+ return new Map(
1458
+ Object.entries(run.workflowResultGenerations).map(([index, generation]) => [Number(index), generation]),
1459
+ );
1460
+ }
1461
+
1435
1462
  /** Derive a stable recovery id from a recovery event's content. */
1436
1463
  export function deriveRecoveryId(event: { runId: string; rotations: unknown[]; timestamp: number }): string {
1437
1464
  const digest = createHash("sha256")