@signalridge/pi-workflows 1.3.2 → 1.4.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/engine.ts CHANGED
@@ -46,10 +46,17 @@ import {
46
46
  type AgentRunOptions,
47
47
  type AgentUsage,
48
48
  type JournalEntry,
49
+ MAX_AGENTS_PER_RUN,
50
+ normalizeAgentRetries,
51
+ normalizeAgentTimeout,
52
+ normalizeConcurrency,
53
+ normalizeMaxAgents,
54
+ normalizeTokenBudget,
49
55
  parseWorkflowScript,
50
56
  runWorkflow,
51
57
  type WorkflowAgentRunner,
52
58
  type WorkflowRunResult,
59
+ type WorkflowRuntimeEvent,
53
60
  } from "./runtime.js";
54
61
 
55
62
  export const MAX_ATTEMPTS_PER_NODE = 3;
@@ -106,6 +113,28 @@ export interface ScriptStartResult {
106
113
  waitAborted?: boolean;
107
114
  }
108
115
 
116
+ interface AgentLifecycleWaiter {
117
+ callIndex: string;
118
+ generation: number;
119
+ onUsage?: (usage: AgentUsage) => void;
120
+ onHistory?: (history: unknown[]) => void;
121
+ executionGeneration: number;
122
+ resolve: (response: ManagedSpawnResponse) => void;
123
+ reject: (error: unknown) => void;
124
+ settled: boolean;
125
+ }
126
+
127
+ interface AgentTerminalDelivery {
128
+ response: ManagedSpawnResponse;
129
+ usage?: AgentUsage;
130
+ history?: unknown[];
131
+ }
132
+
133
+ interface BufferedAgentTerminal extends AgentTerminalDelivery {
134
+ owner: WorkflowOwner;
135
+ executionGeneration: number;
136
+ }
137
+
109
138
  export interface ScriptRunState {
110
139
  run: ScriptRun;
111
140
  /** Frozen run parameters — resume reuses the original values, ignoring new budget/toolset overrides. */
@@ -124,21 +153,11 @@ export interface ScriptRunState {
124
153
  /** Per-call-index generation base for A4 spawnKey rotation (seeded from journal). */
125
154
  generations: Map<string, number>;
126
155
  /** Live dispatch waiters keyed by agent id. */
127
- agentWaiters: Map<
128
- string,
129
- {
130
- callIndex: string;
131
- generation: number;
132
- onUsage?: (usage: AgentUsage) => void;
133
- onHistory?: (history: unknown[]) => void;
134
- executionGeneration: number;
135
- resolve: (r: ManagedSpawnResponse) => void;
136
- reject: (e: unknown) => void;
137
- settled: boolean;
138
- }
139
- >;
156
+ agentWaiters: Map<string, AgentLifecycleWaiter>;
157
+ /** Owner-validated terminal events emitted before their spawn reply/waiter. */
158
+ bufferedTerminals: Map<string, BufferedAgentTerminal>;
140
159
  /** Allocations whose managed-spawn reply has not arrived yet. */
141
- pendingSpawns: Map<string, { spawnKey: string; owner: WorkflowOwner }>;
160
+ pendingSpawns: Map<string, { spawnKey: string; owner: WorkflowOwner; executionGeneration: number }>;
142
161
  /** Journal retry bookkeeping. */
143
162
  pendingJournal: Set<string>;
144
163
  /** Whether a lifecycle suspension is in effect (branch change). */
@@ -153,6 +172,47 @@ function managedTerminalStatus(snapshot: { status: string }): "completed" | "fai
153
172
  return snapshot.status === "completed" ? "completed" : snapshot.status === "failed" ? "failed" : "stopped";
154
173
  }
155
174
 
175
+ function renderWorkflowValue(value: unknown): string {
176
+ if (typeof value === "string") return value;
177
+ try {
178
+ const serialized = JSON.stringify(value, null, 2);
179
+ return serialized === undefined ? String(value) : serialized;
180
+ } catch {
181
+ return String(value);
182
+ }
183
+ }
184
+
185
+ function cloneWorkflowValue(value: unknown, label: string): unknown {
186
+ if (value === undefined) return undefined;
187
+ try {
188
+ const cloned = structuredClone(value);
189
+ const serialized = JSON.stringify(cloned);
190
+ if (serialized === undefined) throw new Error("value is not JSON-serializable");
191
+ if (serialized.length > 200_000) {
192
+ throw new Error("value exceeds the 200000-character workflow persistence limit");
193
+ }
194
+ return cloned;
195
+ } catch (error: unknown) {
196
+ throw new WorkflowError(
197
+ `${label} must be structured-cloneable and JSON-serializable: ${error instanceof Error ? error.message : String(error)}`,
198
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
199
+ { recoverable: false, details: error },
200
+ );
201
+ }
202
+ }
203
+
204
+ function cloneWorkflowArgs(value: unknown): unknown {
205
+ return cloneWorkflowValue(value, "Workflow args");
206
+ }
207
+
208
+ function cloneWorkflowResult(value: unknown): unknown {
209
+ return cloneWorkflowValue(value, "Workflow result");
210
+ }
211
+
212
+ function cloneRuntimeEvent(event: WorkflowRuntimeEvent): WorkflowRuntimeEvent {
213
+ return structuredClone(event);
214
+ }
215
+
156
216
  function resultFromLifecycle(
157
217
  status: "completed" | "failed" | "stopped",
158
218
  agentId: string | undefined,
@@ -179,10 +239,21 @@ function generationFromAttemptId(attemptId: string | undefined): number {
179
239
  return match ? Number(match[1]) : 0;
180
240
  }
181
241
 
242
+ function sameWorkflowOwner(left: WorkflowOwner, right: WorkflowOwner): boolean {
243
+ return (
244
+ left.extension === right.extension &&
245
+ left.runId === right.runId &&
246
+ left.nodeId === right.nodeId &&
247
+ left.attemptId === right.attemptId
248
+ );
249
+ }
250
+
182
251
  export class WorkflowEngine {
183
252
  private readonly runs = new Map<string, ScriptRunState>();
184
253
  /** Fired when a run settles terminal; wired by the UI surface for widget refresh. */
185
254
  onRunSettled: (() => void) | undefined;
255
+ /** Optional in-process observer for phase/task/quality runtime events. */
256
+ onRuntimeEvent: ((runId: string, event: WorkflowRuntimeEvent) => void) | undefined;
186
257
  private readonly waiters = new Map<
187
258
  string,
188
259
  Set<{ resolve: (run: ScriptRun) => void; reject: (e: unknown) => void; settled: boolean; cleanup: () => void }>
@@ -224,60 +295,105 @@ export class WorkflowEngine {
224
295
  }
225
296
 
226
297
  private onLifecycle(eventName: string, raw: unknown): void {
298
+ if (eventName !== "subagents:completed" && eventName !== "subagents:failed") return;
227
299
  if (!raw || typeof raw !== "object") return;
228
300
  const data = raw as Record<string, unknown>;
229
301
  const agentId = typeof data.id === "string" ? data.id : typeof data.agentId === "string" ? data.agentId : undefined;
230
302
  if (!agentId) return;
231
303
 
232
- // Resolve a live dispatch waiter on terminal lifecycle events.
233
- if (eventName === "subagents:completed" || eventName === "subagents:failed") {
234
- const lifecycleStatus =
235
- eventName === "subagents:completed"
236
- ? "completed"
237
- : data.status === "stopped" || data.status === "interrupted"
238
- ? "stopped"
239
- : "failed";
240
- for (const state of this.runs.values()) {
241
- const waiter = state.agentWaiters.get(agentId);
242
- if (!waiter || waiter.settled || waiter.executionGeneration !== state.executionGeneration) continue;
243
- const owner = data.owner;
244
- if (!owner || typeof owner !== "object" || Array.isArray(owner)) continue;
245
- const ownerRecord = owner as Record<string, unknown>;
246
- if (
247
- ownerRecord.extension !== "pi-workflows" ||
248
- ownerRecord.runId !== state.run.runId ||
249
- ownerRecord.nodeId !== `call-${waiter.callIndex}` ||
250
- ownerRecord.attemptId !== `attempt-${waiter.generation}`
251
- )
252
- continue;
253
- const tokenCount =
254
- typeof data.tokenCount === "number"
255
- ? data.tokenCount
256
- : data.tokens &&
257
- typeof data.tokens === "object" &&
258
- typeof (data.tokens as Record<string, unknown>).total === "number"
259
- ? ((data.tokens as Record<string, unknown>).total as number)
260
- : undefined;
261
- waiter.settled = true;
262
- const terminal: ManagedSpawnResponse = {
263
- id: agentId,
264
- terminal: {
265
- status: lifecycleStatus,
266
- ...(typeof data.result === "string" ? { result: data.result } : {}),
267
- ...(typeof data.error === "string" ? { error: data.error } : {}),
268
- ...(typeof data.outputFile === "string" ? { outputFile: data.outputFile } : {}),
269
- ...(tokenCount === undefined ? {} : { tokenCount }),
270
- compactionCount: typeof data.compactionCount === "number" ? data.compactionCount : 0,
271
- completedAt: typeof data.completedAt === "number" ? data.completedAt : Date.now(),
272
- },
273
- };
274
- state.agentWaiters.delete(agentId);
275
- if (tokenCount !== undefined)
276
- waiter.onUsage?.({ input: 0, output: tokenCount, total: tokenCount, cost: 0, cacheRead: 0, cacheWrite: 0 });
277
- if (Array.isArray(data.history)) waiter.onHistory?.(data.history);
278
- waiter.resolve(terminal);
279
- }
304
+ const rawOwner = data.owner;
305
+ if (!rawOwner || typeof rawOwner !== "object" || Array.isArray(rawOwner)) return;
306
+ const ownerRecord = rawOwner as Record<string, unknown>;
307
+ if (
308
+ ownerRecord.extension !== "pi-workflows" ||
309
+ typeof ownerRecord.runId !== "string" ||
310
+ typeof ownerRecord.nodeId !== "string" ||
311
+ typeof ownerRecord.attemptId !== "string"
312
+ )
313
+ return;
314
+ const owner: WorkflowOwner = {
315
+ extension: "pi-workflows",
316
+ runId: ownerRecord.runId,
317
+ nodeId: ownerRecord.nodeId,
318
+ attemptId: ownerRecord.attemptId,
319
+ };
320
+ const state = this.runs.get(owner.runId);
321
+ if (state?.run.status !== "running" || state.lifecycleSuspended || state.controller.signal.aborted) return;
322
+
323
+ const lifecycleStatus =
324
+ eventName === "subagents:completed"
325
+ ? "completed"
326
+ : data.status === "stopped" || data.status === "interrupted"
327
+ ? "stopped"
328
+ : "failed";
329
+ const tokenCount =
330
+ typeof data.tokenCount === "number"
331
+ ? data.tokenCount
332
+ : data.tokens &&
333
+ typeof data.tokens === "object" &&
334
+ typeof (data.tokens as Record<string, unknown>).total === "number"
335
+ ? ((data.tokens as Record<string, unknown>).total as number)
336
+ : undefined;
337
+ const delivery: AgentTerminalDelivery = {
338
+ response: {
339
+ id: agentId,
340
+ terminal: {
341
+ status: lifecycleStatus,
342
+ ...(typeof data.result === "string" ? { result: data.result } : {}),
343
+ ...(typeof data.error === "string" ? { error: data.error } : {}),
344
+ ...(typeof data.outputFile === "string" ? { outputFile: data.outputFile } : {}),
345
+ ...(tokenCount === undefined ? {} : { tokenCount }),
346
+ compactionCount: typeof data.compactionCount === "number" ? data.compactionCount : 0,
347
+ completedAt: typeof data.completedAt === "number" ? data.completedAt : Date.now(),
348
+ },
349
+ },
350
+ ...(tokenCount === undefined
351
+ ? {}
352
+ : { usage: { input: 0, output: tokenCount, total: tokenCount, cost: 0, cacheRead: 0, cacheWrite: 0 } }),
353
+ ...(Array.isArray(data.history) ? { history: [...data.history] } : {}),
354
+ };
355
+
356
+ const waiter = state.agentWaiters.get(agentId);
357
+ if (waiter) {
358
+ if (
359
+ waiter.settled ||
360
+ waiter.executionGeneration !== state.executionGeneration ||
361
+ owner.nodeId !== `call-${waiter.callIndex}` ||
362
+ owner.attemptId !== `attempt-${waiter.generation}`
363
+ )
364
+ return;
365
+ this.resolveAgentWaiter(state, agentId, waiter, delivery);
366
+ return;
280
367
  }
368
+
369
+ // spawn-managed can emit a terminal lifecycle event synchronously before its
370
+ // reply continuation registers the agent-id waiter. Buffer only an owner
371
+ // that is currently awaiting that exact reply in this execution generation.
372
+ const spawnKey = `${owner.runId}/${owner.nodeId}/${owner.attemptId}`;
373
+ const pending = state.pendingSpawns.get(spawnKey);
374
+ if (
375
+ !pending ||
376
+ pending.executionGeneration !== state.executionGeneration ||
377
+ !sameWorkflowOwner(pending.owner, owner) ||
378
+ state.bufferedTerminals.has(spawnKey) ||
379
+ state.bufferedTerminals.size >= MAX_AGENTS_PER_RUN
380
+ )
381
+ return;
382
+ state.bufferedTerminals.set(spawnKey, { ...delivery, owner, executionGeneration: state.executionGeneration });
383
+ }
384
+
385
+ private resolveAgentWaiter(
386
+ state: ScriptRunState,
387
+ agentId: string,
388
+ waiter: AgentLifecycleWaiter,
389
+ delivery: AgentTerminalDelivery,
390
+ ): void {
391
+ if (waiter.settled) return;
392
+ waiter.settled = true;
393
+ state.agentWaiters.delete(agentId);
394
+ if (delivery.usage) waiter.onUsage?.(delivery.usage);
395
+ if (delivery.history) waiter.onHistory?.(delivery.history);
396
+ waiter.resolve(delivery.response);
281
397
  }
282
398
 
283
399
  dispose(): void {
@@ -329,6 +445,7 @@ export class WorkflowEngine {
329
445
  });
330
446
  targets.set(state.run.runId, { agentIds, owners });
331
447
  state.lifecycleSuspended = true;
448
+ state.bufferedTerminals.clear();
332
449
  state.controller.abort(new DOMException("Workflow branch change", "AbortError"));
333
450
  }
334
451
 
@@ -394,18 +511,40 @@ export class WorkflowEngine {
394
511
  const runId = randomUUID();
395
512
  const scriptHash = hashScript(script);
396
513
  const toolset = options.toolset;
514
+ const durableArgs = cloneWorkflowArgs(options.args);
515
+ const runtimeArgs = cloneWorkflowArgs(durableArgs);
516
+ const journalArgs = cloneWorkflowArgs(durableArgs);
517
+ const frozenArgsPresent = durableArgs !== undefined;
518
+ const frozenMaxAgents = options.maxAgents === undefined ? undefined : normalizeMaxAgents(options.maxAgents);
519
+ const frozenConcurrency = options.concurrency === undefined ? undefined : normalizeConcurrency(options.concurrency);
520
+ const frozenAgentRetries =
521
+ options.agentRetries === undefined ? undefined : normalizeAgentRetries(options.agentRetries);
522
+ const frozenTokenBudget = options.tokenBudget === undefined ? undefined : normalizeTokenBudget(options.tokenBudget);
523
+ const frozenAgentTimeoutMs =
524
+ options.agentTimeoutMs === undefined ? undefined : normalizeAgentTimeout(options.agentTimeoutMs);
525
+ const startOptions: ScriptStartOptions = {
526
+ ...options,
527
+ ...(runtimeArgs === undefined ? {} : { args: runtimeArgs }),
528
+ ...(frozenMaxAgents === undefined ? {} : { maxAgents: frozenMaxAgents }),
529
+ ...(frozenConcurrency === undefined ? {} : { concurrency: frozenConcurrency }),
530
+ ...(frozenAgentRetries === undefined ? {} : { agentRetries: frozenAgentRetries }),
531
+ ...(frozenTokenBudget === undefined ? {} : { tokenBudget: frozenTokenBudget }),
532
+ ...(frozenAgentTimeoutMs === undefined ? {} : { agentTimeoutMs: frozenAgentTimeoutMs }),
533
+ };
397
534
  const run: ScriptRun = {
398
535
  runId,
399
536
  schemaVersion: JOURNAL_SCHEMA_VERSION,
400
537
  script,
401
538
  scriptHash,
402
539
  meta,
540
+ ...(durableArgs === undefined ? {} : { args: durableArgs }),
541
+ frozenArgsPresent,
403
542
  ...(toolset ? { toolset } : {}),
404
- ...(options.maxAgents !== undefined ? { frozenMaxAgents: options.maxAgents } : {}),
405
- ...(options.concurrency !== undefined ? { frozenConcurrency: options.concurrency } : {}),
406
- ...(options.agentRetries !== undefined ? { frozenAgentRetries: options.agentRetries } : {}),
407
- ...(options.tokenBudget !== undefined ? { frozenTokenBudget: options.tokenBudget } : {}),
408
- ...(options.agentTimeoutMs !== undefined ? { frozenAgentTimeoutMs: options.agentTimeoutMs } : {}),
543
+ ...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
544
+ ...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
545
+ ...(frozenAgentRetries !== undefined ? { frozenAgentRetries } : {}),
546
+ ...(frozenTokenBudget !== undefined ? { frozenTokenBudget } : {}),
547
+ ...(frozenAgentTimeoutMs !== undefined ? { frozenAgentTimeoutMs } : {}),
409
548
  ...(options.excludeTools ? { frozenExcludeTools: [...options.excludeTools] } : {}),
410
549
  status: "pending",
411
550
  callStatus: {},
@@ -413,6 +552,7 @@ export class WorkflowEngine {
413
552
  attempts: {},
414
553
  attemptIds: {},
415
554
  callResults: {},
555
+ workflowResultGenerations: {},
416
556
  compactions: {},
417
557
  startedAt: now,
418
558
  updatedAt: now,
@@ -424,29 +564,32 @@ export class WorkflowEngine {
424
564
  script,
425
565
  scriptHash,
426
566
  meta,
567
+ ...(journalArgs === undefined ? {} : { args: journalArgs }),
568
+ frozenArgsPresent,
427
569
  ...(toolset ? { toolset } : {}),
428
- ...(options.maxAgents !== undefined ? { frozenMaxAgents: options.maxAgents } : {}),
429
- ...(options.concurrency !== undefined ? { frozenConcurrency: options.concurrency } : {}),
430
- ...(options.agentRetries !== undefined ? { frozenAgentRetries: options.agentRetries } : {}),
431
- ...(options.tokenBudget !== undefined ? { frozenTokenBudget: options.tokenBudget } : {}),
432
- ...(options.agentTimeoutMs !== undefined ? { frozenAgentTimeoutMs: options.agentTimeoutMs } : {}),
570
+ ...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
571
+ ...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
572
+ ...(frozenAgentRetries !== undefined ? { frozenAgentRetries } : {}),
573
+ ...(frozenTokenBudget !== undefined ? { frozenTokenBudget } : {}),
574
+ ...(frozenAgentTimeoutMs !== undefined ? { frozenAgentTimeoutMs } : {}),
433
575
  ...(options.excludeTools ? { frozenExcludeTools: [...options.excludeTools] } : {}),
434
576
  timestamp: now,
435
577
  });
436
578
  const state: ScriptRunState = {
437
579
  run,
438
580
  frozenOptions: {
439
- ...(options.tokenBudget !== undefined ? { tokenBudget: options.tokenBudget } : {}),
440
- ...(options.maxAgents !== undefined ? { maxAgents: options.maxAgents } : {}),
441
- ...(options.concurrency !== undefined ? { concurrency: options.concurrency } : {}),
442
- ...(options.agentRetries !== undefined ? { agentRetries: options.agentRetries } : {}),
443
- ...(options.agentTimeoutMs !== undefined ? { agentTimeoutMs: options.agentTimeoutMs } : {}),
581
+ ...(frozenTokenBudget !== undefined ? { tokenBudget: frozenTokenBudget } : {}),
582
+ ...(frozenMaxAgents !== undefined ? { maxAgents: frozenMaxAgents } : {}),
583
+ ...(frozenConcurrency !== undefined ? { concurrency: frozenConcurrency } : {}),
584
+ ...(frozenAgentRetries !== undefined ? { agentRetries: frozenAgentRetries } : {}),
585
+ ...(frozenAgentTimeoutMs !== undefined ? { agentTimeoutMs: frozenAgentTimeoutMs } : {}),
444
586
  ...(options.toolset ? { toolset: options.toolset } : {}),
445
587
  ...(options.excludeTools ? { excludeTools: [...options.excludeTools] } : {}),
446
588
  },
447
589
  controller: new AbortController(),
448
590
  generations: new Map(),
449
591
  agentWaiters: new Map(),
592
+ bufferedTerminals: new Map(),
450
593
  pendingSpawns: new Map(),
451
594
  pendingJournal: new Set(),
452
595
  lifecycleSuspended: false,
@@ -455,7 +598,7 @@ export class WorkflowEngine {
455
598
  this.runs.set(runId, state);
456
599
  this.setWorkflowStatus(run, "running");
457
600
  state.executionGeneration += 1;
458
- state.execution = this.execute(runId, script, options, undefined, state.executionGeneration);
601
+ state.execution = this.execute(runId, script, startOptions, undefined, state.executionGeneration);
459
602
  // A run-fatal abort/stop already journals its terminal state; never let a
460
603
  // leftover rejection become an unhandled promise rejection.
461
604
  void state.execution.catch(() => {});
@@ -468,7 +611,7 @@ export class WorkflowEngine {
468
611
  runId,
469
612
  status: settled.status,
470
613
  background: false,
471
- ...(scriptResult !== undefined ? { result: String(scriptResult) } : {}),
614
+ ...(scriptResult !== undefined ? { result: renderWorkflowValue(scriptResult) } : {}),
472
615
  ...(settled.error ? { error: settled.error } : {}),
473
616
  };
474
617
  } catch (error: unknown) {
@@ -521,6 +664,26 @@ export class WorkflowEngine {
521
664
  }),
522
665
  );
523
666
  }
667
+ let resumeArgs: unknown;
668
+ if (run.frozenArgsPresent === undefined && run.args === undefined && options.args !== undefined) {
669
+ const durableArgs = cloneWorkflowArgs(options.args);
670
+ const runArgs = cloneWorkflowArgs(options.args);
671
+ resumeArgs = cloneWorkflowArgs(options.args);
672
+ const timestamp = Date.now();
673
+ this.appendRequired({
674
+ kind: "run_args",
675
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
676
+ runId,
677
+ args: durableArgs,
678
+ timestamp,
679
+ });
680
+ run.args = runArgs;
681
+ run.updatedAt = timestamp;
682
+ } else {
683
+ // A current false marker freezes intentional omission; resume-supplied
684
+ // args are fallback input only for genuinely legacy schema-v3 runs.
685
+ resumeArgs = cloneWorkflowArgs(run.args);
686
+ }
524
687
  const resumeJournal = buildResumeJournal(entries);
525
688
  // Seed generation bases from journaled attemptIds so a live re-dispatch of a
526
689
  // previously-journaled call never reuses a spawnKey (A4).
@@ -529,12 +692,13 @@ export class WorkflowEngine {
529
692
  }
530
693
  this.setWorkflowStatus(run, "running");
531
694
  state.controller = new AbortController();
532
- // Freeze semantics: original run parameters win over new options.
533
- // Only signal/confirm/args/background/loadSavedWorkflow/mainModel are per-resume context;
534
- // budget/scale/toolset are frozen from the original start.
695
+ // Freeze semantics: original run parameters and args win over new options.
696
+ // Only signal/confirm/background/loadSavedWorkflow/mainModel are per-resume context;
697
+ // input/budget/scale/toolset are frozen from the original start.
535
698
  const frozen = state.frozenOptions;
536
699
  const resumeOptions: ScriptStartOptions = {
537
700
  ...options,
701
+ args: resumeArgs,
538
702
  tokenBudget: frozen.tokenBudget,
539
703
  maxAgents: frozen.maxAgents,
540
704
  concurrency: frozen.concurrency,
@@ -543,6 +707,7 @@ export class WorkflowEngine {
543
707
  toolset: frozen.toolset,
544
708
  excludeTools: frozen.excludeTools ? [...frozen.excludeTools] : undefined,
545
709
  };
710
+ state.bufferedTerminals.clear();
546
711
  state.executionGeneration += 1;
547
712
  state.execution = this.execute(runId, run.script, resumeOptions, resumeJournal, state.executionGeneration);
548
713
  void state.execution.catch(() => {});
@@ -624,6 +789,7 @@ export class WorkflowEngine {
624
789
  if (!state) throw new Error(`workflow run not found: ${runId}`);
625
790
  if (isTerminalWorkflow(state.run.status) || state.run.status === "paused" || state.run.status === "interrupted")
626
791
  return state.run;
792
+ if (signal?.aborted) throw new WorkflowWaitAbortedError();
627
793
  const all =
628
794
  this.waiters.get(runId) ??
629
795
  new Set<{
@@ -712,24 +878,42 @@ export class WorkflowEngine {
712
878
  signal: state.controller.signal,
713
879
  maxAgents: options.maxAgents,
714
880
  runId,
881
+ executionNonce: `${generation}-${randomUUID()}`,
715
882
  resumeJournal,
716
883
  toolset: options.toolset,
717
884
  excludeTools: options.excludeTools,
718
885
  confirm: options.confirm,
719
886
  loadSavedWorkflow: options.loadSavedWorkflow,
720
887
  onAgentJournal: (entry) => {
721
- // Nested workflow frames use their own run ids. They are intentionally
722
- // not persisted as top-level call facts until a namespaced journal exists;
723
- // mixing them into the parent would quarantine the run on replay.
888
+ if (state.executionGeneration !== generation) return;
889
+ // Nested workflow frames keep their own lifecycle visibility, but their
890
+ // individual call facts are not top-level replay keys. The parent
891
+ // workflow_result fact below owns the nested replay boundary.
724
892
  if (entry.runId === undefined || entry.runId === runId) this.journalCallResult(runId, entry);
725
893
  },
726
- onRuntimeEvent: () => {},
894
+ onWorkflowJournal: (entry) => {
895
+ if (state.executionGeneration !== generation) return;
896
+ this.journalWorkflowResult(runId, entry);
897
+ },
898
+ onRuntimeEvent: (event) => {
899
+ if (state.executionGeneration !== generation) return;
900
+ try {
901
+ this.events.emit("pi-workflows:runtime", { runId, event: cloneRuntimeEvent(event) });
902
+ } catch {
903
+ // Cross-extension observers cannot change workflow execution.
904
+ }
905
+ try {
906
+ this.onRuntimeEvent?.(runId, cloneRuntimeEvent(event));
907
+ } catch {
908
+ // UI observers are diagnostic surfaces and are independently contained.
909
+ }
910
+ },
727
911
  });
728
912
  const live = this.runs.get(runId);
729
- if (live && state.executionGeneration === generation) live.result = result.result;
730
913
  const run = live?.run;
731
914
  if (run && state.executionGeneration === generation && !isTerminalWorkflow(run.status)) {
732
- this.setWorkflowStatus(run, "completed");
915
+ this.setWorkflowStatus(run, "completed", undefined, undefined, result.result);
916
+ live.result = result.result;
733
917
  }
734
918
  return result;
735
919
  } catch (error: unknown) {
@@ -758,9 +942,9 @@ export class WorkflowEngine {
758
942
 
759
943
  /**
760
944
  * Dispatch one live agent() call through spawn-managed with an A4-rotated
761
- * spawnKey: `${runId}/call-${callIndex}/attempt-${generation}`. The generation
762
- * combines the journaled base (so a resume of an edited call lands on a fresh
763
- * key) with the runtime's per-attempt counter.
945
+ * spawnKey: `${runId}/call-${callIndex}/attempt-${generation}`. Top-level calls
946
+ * use numeric call indexes; nested workflow frames use a generation/nonce
947
+ * namespace so a changed child cannot reuse a stale managed identity.
764
948
  */
765
949
  private async dispatchAgent(runId: string, prompt: string, runOptions: AgentRunOptions): Promise<unknown> {
766
950
  const state = this.runs.get(runId);
@@ -773,13 +957,19 @@ export class WorkflowEngine {
773
957
  const run = state.run;
774
958
  const executionGeneration = state.executionGeneration;
775
959
  const callIndex = runOptions.callIndex ?? state.generations.size;
960
+ const frameRunId = runOptions.runId ?? runId;
961
+ const nested = frameRunId !== runId;
962
+ // Top-level calls use their numeric lexical index. Nested frames use a
963
+ // run-local namespace so child call 0 can never collide with the parent's
964
+ // call 0 or with a sibling nested workflow. The managed owner remains the
965
+ // top-level run: nested workflow results are journaled as one parent fact.
966
+ const nodeId = nested ? `${frameRunId}:${callIndex}` : String(callIndex);
776
967
  // The spawnKey identity carries the `call-` prefix (A4):
777
- // spawnKey = `${runId}/call-${callIndex}/attempt-${generation}`
778
- // The journal node identity is the bare call index, and the journal
779
- // attemptId is the full `${runId}/${nodeId}/attempt-${generation}` path the
780
- // journal replay validates.
781
- const spawnNodeId = `call-${callIndex}`;
782
- const nodeId = String(callIndex);
968
+ // spawnKey = `${runId}/call-${nodeId}/attempt-${generation}`
969
+ // The journal node identity is the bare call index for top-level calls;
970
+ // nested identities are in-memory only and are never persisted as agent
971
+ // facts.
972
+ const spawnNodeId = `call-${nodeId}`;
783
973
  const base = state.generations.get(nodeId) ?? 0;
784
974
  const generation = base + (runOptions.attempt ?? 1);
785
975
  state.generations.set(nodeId, generation);
@@ -803,23 +993,27 @@ export class WorkflowEngine {
803
993
  ...(runOptions.isolation === undefined ? {} : { isolation: runOptions.isolation }),
804
994
  };
805
995
 
806
- // Journal the attempt rotation so a later resume knows the generation base.
807
- this.persist(runId, `call-attempt:${nodeId}`, () =>
808
- this.write({
809
- kind: "call_attempt",
810
- schemaVersion: JOURNAL_SCHEMA_VERSION,
811
- runId,
812
- nodeId,
813
- attemptId,
814
- generation,
815
- owner: { extension: "pi-workflows", runId, nodeId, attemptId },
816
- timestamp: Date.now(),
817
- }),
818
- );
819
- run.callStatus[nodeId] = "running";
820
- run.attempts[nodeId] = generation;
821
- run.attemptIds[nodeId] = attemptId;
822
- run.updatedAt = Date.now();
996
+ // Journal the attempt rotation only for top-level calls. A nested frame is
997
+ // represented by its parent workflow_result replay boundary instead of
998
+ // leaking child call facts into the parent's numeric call namespace.
999
+ if (!nested) {
1000
+ this.persist(runId, `call-attempt:${nodeId}`, () =>
1001
+ this.write({
1002
+ kind: "call_attempt",
1003
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1004
+ runId,
1005
+ nodeId,
1006
+ attemptId,
1007
+ generation,
1008
+ owner: { extension: "pi-workflows", runId, nodeId, attemptId },
1009
+ timestamp: Date.now(),
1010
+ }),
1011
+ );
1012
+ run.callStatus[nodeId] = "running";
1013
+ run.attempts[nodeId] = generation;
1014
+ run.attemptIds[nodeId] = attemptId;
1015
+ run.updatedAt = Date.now();
1016
+ }
823
1017
 
824
1018
  // Abort linkage: an external signal (pause/stop/Esc) cancels the wait and
825
1019
  // stops the owned agent. Track the spawn key before the RPC reply arrives so
@@ -832,9 +1026,9 @@ export class WorkflowEngine {
832
1026
  nodeId: spawnNodeId,
833
1027
  attemptId: spawnAttemptId,
834
1028
  };
835
- const pendingKey = `${nodeId}\u0000${generation}`;
836
- state.pendingSpawns.set(pendingKey, { spawnKey, owner });
1029
+ state.pendingSpawns.set(spawnKey, { spawnKey, owner, executionGeneration });
837
1030
  const reconcilePending = () => {
1031
+ state.bufferedTerminals.delete(spawnKey);
838
1032
  const reconciliation = this.client.reconcileManaged?.(spawnKey, owner);
839
1033
  void reconciliation?.catch(() => {});
840
1034
  };
@@ -848,7 +1042,7 @@ export class WorkflowEngine {
848
1042
  response = await this.client.spawn(task, runId, spawnNodeId, spawnAttemptId, undefined, externalSignal);
849
1043
  } finally {
850
1044
  externalSignal.removeEventListener("abort", onPendingAbort);
851
- state.pendingSpawns.delete(pendingKey);
1045
+ state.pendingSpawns.delete(spawnKey);
852
1046
  }
853
1047
  const responseId = typeof response === "string" ? response : response.id;
854
1048
  if (
@@ -856,6 +1050,7 @@ export class WorkflowEngine {
856
1050
  state.controller.signal.aborted ||
857
1051
  externalSignal.aborted
858
1052
  ) {
1053
+ state.bufferedTerminals.delete(spawnKey);
859
1054
  await this.client
860
1055
  .stopOwned?.(responseId, { extension: "pi-workflows", runId, nodeId: spawnNodeId, attemptId: spawnAttemptId })
861
1056
  .catch(() => {});
@@ -868,6 +1063,7 @@ export class WorkflowEngine {
868
1063
  if (typeof response === "string") {
869
1064
  pendingId = response;
870
1065
  } else if (response.terminal) {
1066
+ state.bufferedTerminals.delete(spawnKey);
871
1067
  // The spawn response already carries a terminal snapshot (fast completion
872
1068
  // or interrupted replay). The runtime journals successful calls itself
873
1069
  // (onAgentJournal); a terminal snapshot that is NOT a success is
@@ -893,7 +1089,7 @@ export class WorkflowEngine {
893
1089
  terminal.tokenCount,
894
1090
  );
895
1091
  result.attemptId = attemptId;
896
- this.commitCallTerminal(runId, nodeId, result);
1092
+ if (!nested) this.commitCallTerminal(runId, nodeId, result);
897
1093
  if (status === "failed" && terminal.error) {
898
1094
  throw wrapError(new Error(terminal.error), { agentLabel: runOptions.label });
899
1095
  }
@@ -908,6 +1104,7 @@ export class WorkflowEngine {
908
1104
  if (waiter && !waiter.settled) {
909
1105
  waiter.settled = true;
910
1106
  state.agentWaiters.delete(pendingId);
1107
+ state.bufferedTerminals.delete(spawnKey);
911
1108
  void this.client
912
1109
  .stopOwned?.(pendingId, {
913
1110
  extension: "pi-workflows",
@@ -920,10 +1117,11 @@ export class WorkflowEngine {
920
1117
  }
921
1118
  };
922
1119
 
923
- // Wait for the terminal lifecycle event.
1120
+ // Wait for the terminal lifecycle event, consuming a matching terminal that
1121
+ // arrived while the managed-spawn reply was still pending.
924
1122
  const terminal = await new Promise<ManagedSpawnResponse>((resolve, reject) => {
925
- const waiter = {
926
- callIndex: String(callIndex),
1123
+ const waiter: AgentLifecycleWaiter = {
1124
+ callIndex: nodeId,
927
1125
  generation,
928
1126
  executionGeneration,
929
1127
  onUsage: runOptions.onUsage,
@@ -934,7 +1132,24 @@ export class WorkflowEngine {
934
1132
  };
935
1133
  state.agentWaiters.set(pendingId, waiter);
936
1134
  externalSignal.addEventListener("abort", waitAbort, { once: true });
937
- if (externalSignal.aborted) waitAbort();
1135
+ if (externalSignal.aborted) {
1136
+ waitAbort();
1137
+ return;
1138
+ }
1139
+
1140
+ const buffered = state.bufferedTerminals.get(spawnKey);
1141
+ if (!buffered) return;
1142
+ state.bufferedTerminals.delete(spawnKey);
1143
+ if (
1144
+ buffered.executionGeneration !== executionGeneration ||
1145
+ state.executionGeneration !== executionGeneration ||
1146
+ state.lifecycleSuspended ||
1147
+ state.controller.signal.aborted ||
1148
+ buffered.response.id !== pendingId ||
1149
+ !sameWorkflowOwner(buffered.owner, owner)
1150
+ )
1151
+ return;
1152
+ this.resolveAgentWaiter(state, pendingId, waiter, buffered);
938
1153
  }).finally(() => {
939
1154
  externalSignal.removeEventListener("abort", waitAbort);
940
1155
  });
@@ -958,7 +1173,7 @@ export class WorkflowEngine {
958
1173
  snapshot.tokenCount,
959
1174
  );
960
1175
  result.attemptId = attemptId;
961
- this.commitCallTerminal(runId, nodeId, result);
1176
+ if (!nested) this.commitCallTerminal(runId, nodeId, result);
962
1177
  if (status === "failed" && snapshot.error) {
963
1178
  throw wrapError(new Error(snapshot.error), { agentLabel: runOptions.label });
964
1179
  }
@@ -1000,6 +1215,40 @@ export class WorkflowEngine {
1000
1215
  run.updatedAt = Date.now();
1001
1216
  }
1002
1217
 
1218
+ private journalWorkflowResult(runId: string, entry: JournalEntry): void {
1219
+ const state = this.runs.get(runId);
1220
+ if (!state) return;
1221
+ const nodeId = String(entry.index);
1222
+ const result: CallResult = {
1223
+ status: "completed",
1224
+ ...(entry.result === undefined ? {} : { result: entry.result }),
1225
+ compactionCount: 0,
1226
+ updatedAt: Date.now(),
1227
+ };
1228
+ const event: JournalEvent = {
1229
+ kind: "workflow_result",
1230
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1231
+ runId,
1232
+ nodeId,
1233
+ result,
1234
+ generation: entry.generation ?? 1,
1235
+ ...(entry.agentCount === undefined ? {} : { agentCount: entry.agentCount }),
1236
+ callHash: entry.hash,
1237
+ ...(entry.storeDelta !== undefined && Object.keys(entry.storeDelta).length > 0
1238
+ ? { storeDelta: entry.storeDelta }
1239
+ : {}),
1240
+ timestamp: Date.now(),
1241
+ };
1242
+ // The parent cannot become completed until this replay boundary is durable;
1243
+ // otherwise a transient append retry would arrive after the terminal
1244
+ // transition and be discarded as stale during restore.
1245
+ this.appendRequired(event);
1246
+ state.run.workflowResultGenerations[nodeId] = event.generation;
1247
+ state.run.callResults[nodeId] = result;
1248
+ state.run.callStatus[nodeId] = "completed";
1249
+ state.run.updatedAt = Date.now();
1250
+ }
1251
+
1003
1252
  private commitCallTerminal(runId: string, nodeId: string, result: CallResult): void {
1004
1253
  const state = this.runs.get(runId);
1005
1254
  if (!state) return;
@@ -1031,6 +1280,7 @@ export class WorkflowEngine {
1031
1280
  void reconciliation?.catch(() => {});
1032
1281
  }
1033
1282
  state.pendingSpawns.clear();
1283
+ state.bufferedTerminals.clear();
1034
1284
  }
1035
1285
 
1036
1286
  private async quiescePendingSpawns(state: ScriptRunState): Promise<WorkflowQuiesceResult> {
@@ -1183,7 +1433,7 @@ export class WorkflowEngine {
1183
1433
  this.journal.append(event);
1184
1434
  } catch (error: unknown) {
1185
1435
  throw new WorkflowError(
1186
- `Could not persist workflow start: ${error instanceof Error ? error.message : String(error)}`,
1436
+ `Could not persist workflow ${event.kind}: ${error instanceof Error ? error.message : String(error)}`,
1187
1437
  WorkflowErrorCode.PERSISTENCE_ERROR,
1188
1438
  { recoverable: true, details: error },
1189
1439
  );
@@ -1271,8 +1521,13 @@ export class WorkflowEngine {
1271
1521
  status: ScriptRun["status"],
1272
1522
  error?: string,
1273
1523
  terminalIntent?: "stop" | "failure",
1524
+ finalResult?: unknown,
1274
1525
  ): void {
1275
1526
  if (isTerminalWorkflow(run.status) && !isTerminalWorkflow(status)) return;
1527
+ // Validate and isolate terminal values before best-effort persistence or any
1528
+ // status mutation. A bad result must still be able to transition the run to failed.
1529
+ const durableFinalResult = finalResult === undefined ? undefined : cloneWorkflowResult(finalResult);
1530
+ const runFinalResult = finalResult === undefined ? undefined : cloneWorkflowResult(finalResult);
1276
1531
  const timestamp = Date.now();
1277
1532
  this.persist(run.runId, `transition:${status}`, () =>
1278
1533
  this.write({
@@ -1281,15 +1536,18 @@ export class WorkflowEngine {
1281
1536
  runId: run.runId,
1282
1537
  status,
1283
1538
  ...(error !== undefined ? { error } : {}),
1539
+ ...(durableFinalResult !== undefined ? { finalResult: durableFinalResult } : {}),
1284
1540
  ...(terminalIntent !== undefined ? { terminalIntent } : {}),
1285
1541
  timestamp,
1286
1542
  }),
1287
1543
  );
1288
1544
  run.status = status;
1289
1545
  if (error !== undefined) run.error = error;
1546
+ if (runFinalResult !== undefined) run.finalResult = runFinalResult;
1290
1547
  if (terminalIntent !== undefined) run.terminalIntent = terminalIntent;
1291
1548
  run.updatedAt = timestamp;
1292
1549
  if (isTerminalWorkflow(status)) {
1550
+ this.runs.get(run.runId)?.bufferedTerminals.clear();
1293
1551
  this.settleWaiters(run.runId, run);
1294
1552
  this.onRunSettled?.();
1295
1553
  }
@@ -1326,6 +1584,7 @@ export class WorkflowEngine {
1326
1584
  controller: new AbortController(),
1327
1585
  generations: new Map(),
1328
1586
  agentWaiters: new Map(),
1587
+ bufferedTerminals: new Map(),
1329
1588
  pendingSpawns: new Map(),
1330
1589
  pendingJournal: new Set(),
1331
1590
  lifecycleSuspended: false,
@@ -1374,6 +1633,7 @@ export class WorkflowEngine {
1374
1633
  startedAt: run.startedAt,
1375
1634
  updatedAt: run.updatedAt,
1376
1635
  ...(run.error ? { error: run.error.slice(0, 2_000) } : {}),
1636
+ ...(run.finalResult !== undefined ? { resultPreview: renderWorkflowValue(run.finalResult).slice(0, 8_000) } : {}),
1377
1637
  };
1378
1638
  if (!detailed) return base;
1379
1639
  const calls = Object.entries(run.callStatus)