@signalridge/pi-workflows 1.3.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.ts CHANGED
@@ -19,10 +19,12 @@
19
19
 
20
20
  import { createHash, randomUUID } from "node:crypto";
21
21
  import type { ManagedSpawnResponse } from "@signalridge/pi-subagents-protocol";
22
+ import { isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
22
23
  import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
23
24
  import {
24
25
  buildResumeJournal,
25
26
  type CallResult,
27
+ type CallTierIdentity,
26
28
  deriveRecoveryId,
27
29
  JOURNAL_ENTRY_TYPE,
28
30
  JOURNAL_SCHEMA_VERSION,
@@ -39,6 +41,7 @@ import {
39
41
  import {
40
42
  createManagedSpawnClient,
41
43
  type DispatchTask,
44
+ type ManagedProtocolCheck,
42
45
  type ManagedSpawnClient,
43
46
  type WorkflowEventBus,
44
47
  } from "./rpc-client.js";
@@ -46,10 +49,17 @@ import {
46
49
  type AgentRunOptions,
47
50
  type AgentUsage,
48
51
  type JournalEntry,
52
+ MAX_AGENTS_PER_RUN,
53
+ normalizeAgentRetries,
54
+ normalizeAgentTimeout,
55
+ normalizeConcurrency,
56
+ normalizeMaxAgents,
57
+ normalizeTokenBudget,
49
58
  parseWorkflowScript,
50
59
  runWorkflow,
51
60
  type WorkflowAgentRunner,
52
61
  type WorkflowRunResult,
62
+ type WorkflowRuntimeEvent,
53
63
  } from "./runtime.js";
54
64
 
55
65
  export const MAX_ATTEMPTS_PER_NODE = 3;
@@ -78,6 +88,8 @@ export interface ScriptStartOptions {
78
88
  concurrency?: number;
79
89
  agentRetries?: number;
80
90
  tokenBudget?: number | null;
91
+ /** True for a script this package ships; see WorkflowRunOptions.shippedScript. */
92
+ shippedScript?: boolean;
81
93
  agentTimeoutMs?: number | null;
82
94
  toolset?: string;
83
95
  excludeTools?: string[];
@@ -93,7 +105,13 @@ export interface ScriptStartOptions {
93
105
  timeoutMs?: number;
94
106
  },
95
107
  ) => Promise<unknown>;
96
- loadSavedWorkflow?: (name: string) => string | undefined;
108
+ /**
109
+ * Resolve a nested `workflow(name)` reference. The result reports whether the
110
+ * script it returned ships with this package, because that decides how the
111
+ * child frame treats a tier name the host does not define — see
112
+ * `WorkflowRunOptions.shippedScript`.
113
+ */
114
+ loadSavedWorkflow?: (name: string) => { script: string; shippedScript?: boolean } | undefined;
97
115
  mainModel?: string;
98
116
  }
99
117
 
@@ -106,12 +124,41 @@ export interface ScriptStartResult {
106
124
  waitAborted?: boolean;
107
125
  }
108
126
 
127
+ interface AgentLifecycleWaiter {
128
+ callIndex: string;
129
+ generation: number;
130
+ onUsage?: (usage: AgentUsage) => void;
131
+ onHistory?: (history: unknown[]) => void;
132
+ executionGeneration: number;
133
+ resolve: (response: ManagedSpawnResponse) => void;
134
+ reject: (error: unknown) => void;
135
+ settled: boolean;
136
+ }
137
+
138
+ interface AgentTerminalDelivery {
139
+ response: ManagedSpawnResponse;
140
+ usage?: AgentUsage;
141
+ history?: unknown[];
142
+ }
143
+
144
+ interface BufferedAgentTerminal extends AgentTerminalDelivery {
145
+ owner: WorkflowOwner;
146
+ executionGeneration: number;
147
+ }
148
+
109
149
  export interface ScriptRunState {
110
150
  run: ScriptRun;
111
151
  /** Frozen run parameters — resume reuses the original values, ignoring new budget/toolset overrides. */
112
152
  frozenOptions: Pick<
113
153
  ScriptStartOptions,
114
- "tokenBudget" | "maxAgents" | "concurrency" | "agentRetries" | "agentTimeoutMs" | "toolset" | "excludeTools"
154
+ | "tokenBudget"
155
+ | "maxAgents"
156
+ | "concurrency"
157
+ | "agentRetries"
158
+ | "agentTimeoutMs"
159
+ | "toolset"
160
+ | "shippedScript"
161
+ | "excludeTools"
115
162
  >;
116
163
  /** Abort controller for this run's script execution. */
117
164
  controller: AbortController;
@@ -123,22 +170,14 @@ export interface ScriptRunState {
123
170
  result?: unknown;
124
171
  /** Per-call-index generation base for A4 spawnKey rotation (seeded from journal). */
125
172
  generations: Map<string, number>;
173
+ /** Resolved tier identity for live calls, including stopped terminal results. */
174
+ callTiers: Map<string, CallTierIdentity>;
126
175
  /** 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
- >;
176
+ agentWaiters: Map<string, AgentLifecycleWaiter>;
177
+ /** Owner-validated terminal events emitted before their spawn reply/waiter. */
178
+ bufferedTerminals: Map<string, BufferedAgentTerminal>;
140
179
  /** Allocations whose managed-spawn reply has not arrived yet. */
141
- pendingSpawns: Map<string, { spawnKey: string; owner: WorkflowOwner }>;
180
+ pendingSpawns: Map<string, { spawnKey: string; owner: WorkflowOwner; executionGeneration: number }>;
142
181
  /** Journal retry bookkeeping. */
143
182
  pendingJournal: Set<string>;
144
183
  /** Whether a lifecycle suspension is in effect (branch change). */
@@ -153,6 +192,60 @@ function managedTerminalStatus(snapshot: { status: string }): "completed" | "fai
153
192
  return snapshot.status === "completed" ? "completed" : snapshot.status === "failed" ? "failed" : "stopped";
154
193
  }
155
194
 
195
+ function updateCallTierIdentity(state: ScriptRunState, nodeId: string, tier: unknown): boolean {
196
+ if (!isManagedAgentTier(tier)) return false;
197
+ if (state.callTiers.get(nodeId)?.tier === tier) return false;
198
+ const identity: CallTierIdentity = { tier };
199
+ state.callTiers.set(nodeId, identity);
200
+ state.run.callTiers[nodeId] = identity;
201
+ return true;
202
+ }
203
+
204
+ function callIndexFromOwnerNode(nodeId: string): string | undefined {
205
+ return nodeId.startsWith("call-") ? nodeId.slice("call-".length) : undefined;
206
+ }
207
+
208
+ function renderWorkflowValue(value: unknown): string {
209
+ if (typeof value === "string") return value;
210
+ try {
211
+ const serialized = JSON.stringify(value, null, 2);
212
+ return serialized === undefined ? String(value) : serialized;
213
+ } catch {
214
+ return String(value);
215
+ }
216
+ }
217
+
218
+ function cloneWorkflowValue(value: unknown, label: string): unknown {
219
+ if (value === undefined) return undefined;
220
+ try {
221
+ const cloned = structuredClone(value);
222
+ const serialized = JSON.stringify(cloned);
223
+ if (serialized === undefined) throw new Error("value is not JSON-serializable");
224
+ if (serialized.length > 200_000) {
225
+ throw new Error("value exceeds the 200000-character workflow persistence limit");
226
+ }
227
+ return cloned;
228
+ } catch (error: unknown) {
229
+ throw new WorkflowError(
230
+ `${label} must be structured-cloneable and JSON-serializable: ${error instanceof Error ? error.message : String(error)}`,
231
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
232
+ { recoverable: false, details: error },
233
+ );
234
+ }
235
+ }
236
+
237
+ function cloneWorkflowArgs(value: unknown): unknown {
238
+ return cloneWorkflowValue(value, "Workflow args");
239
+ }
240
+
241
+ function cloneWorkflowResult(value: unknown): unknown {
242
+ return cloneWorkflowValue(value, "Workflow result");
243
+ }
244
+
245
+ function cloneRuntimeEvent(event: WorkflowRuntimeEvent): WorkflowRuntimeEvent {
246
+ return structuredClone(event);
247
+ }
248
+
156
249
  function resultFromLifecycle(
157
250
  status: "completed" | "failed" | "stopped",
158
251
  agentId: string | undefined,
@@ -160,10 +253,12 @@ function resultFromLifecycle(
160
253
  error: string | undefined,
161
254
  compactionCount: number,
162
255
  tokenCount?: number,
256
+ tier?: string,
163
257
  ): CallResult {
164
258
  return {
165
259
  status,
166
260
  ...(agentId ? { agentId } : {}),
261
+ ...(tier === undefined ? {} : { tier }),
167
262
  ...(result !== undefined && result.length > 0 ? { result } : {}),
168
263
  ...(error !== undefined && error.length > 0 ? { error } : {}),
169
264
  ...(tokenCount !== undefined ? { tokenCount } : {}),
@@ -179,15 +274,29 @@ function generationFromAttemptId(attemptId: string | undefined): number {
179
274
  return match ? Number(match[1]) : 0;
180
275
  }
181
276
 
277
+ function sameWorkflowOwner(left: WorkflowOwner, right: WorkflowOwner): boolean {
278
+ return (
279
+ left.extension === right.extension &&
280
+ left.runId === right.runId &&
281
+ left.nodeId === right.nodeId &&
282
+ left.attemptId === right.attemptId
283
+ );
284
+ }
285
+
182
286
  export class WorkflowEngine {
183
287
  private readonly runs = new Map<string, ScriptRunState>();
184
288
  /** Fired when a run settles terminal; wired by the UI surface for widget refresh. */
185
289
  onRunSettled: (() => void) | undefined;
290
+ /** Optional in-process observer for phase/task/quality runtime events. */
291
+ onRuntimeEvent: ((runId: string, event: WorkflowRuntimeEvent) => void) | undefined;
186
292
  private readonly waiters = new Map<
187
293
  string,
188
294
  Set<{ resolve: (run: ScriptRun) => void; reject: (e: unknown) => void; settled: boolean; cleanup: () => void }>
189
295
  >();
190
296
  private unsubscribeLifecycle: () => void;
297
+ private readonly protocolGate: () => Promise<ManagedProtocolCheck>;
298
+ private readonly disposeController = new AbortController();
299
+ private disposed = false;
191
300
  private lifecyclePaused = false;
192
301
  private branchQuiescePromise: Promise<WorkflowQuiesceResult> | undefined;
193
302
  private recoveryBranchGeneration = 0;
@@ -202,10 +311,37 @@ export class WorkflowEngine {
202
311
  private readonly client: ManagedSpawnClient,
203
312
  private readonly journal: JournalWriter,
204
313
  private readonly readEntries: () => readonly SessionEntryLike[] = () => [],
314
+ protocolGate?: () => Promise<ManagedProtocolCheck>,
205
315
  ) {
316
+ this.protocolGate =
317
+ protocolGate ??
318
+ (() => this.client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable")));
206
319
  this.unsubscribeLifecycle = this.attachLifecycle();
207
320
  }
208
321
 
322
+ private async awaitProtocol(): Promise<ManagedProtocolCheck> {
323
+ if (this.disposed) throw new WorkflowEngineDisposedError();
324
+ let rejectDisposed: ((reason?: unknown) => void) | undefined;
325
+ const disposed = new Promise<never>((_, reject) => {
326
+ rejectDisposed = reject;
327
+ });
328
+ const onDisposed = () => rejectDisposed?.(new WorkflowEngineDisposedError());
329
+ this.disposeController.signal.addEventListener("abort", onDisposed, { once: true });
330
+ let result: ManagedProtocolCheck;
331
+ try {
332
+ result = await Promise.race([this.protocolGate(), disposed]);
333
+ } finally {
334
+ this.disposeController.signal.removeEventListener("abort", onDisposed);
335
+ }
336
+ if (this.disposed) throw new WorkflowEngineDisposedError();
337
+ return result;
338
+ }
339
+
340
+ /** Whether this engine can still accept new workflow executions. */
341
+ isDisposed(): boolean {
342
+ return this.disposed;
343
+ }
344
+
209
345
  private attachLifecycle(): () => void {
210
346
  const unsubs = [
211
347
  "subagents:created",
@@ -224,63 +360,157 @@ export class WorkflowEngine {
224
360
  }
225
361
 
226
362
  private onLifecycle(eventName: string, raw: unknown): void {
363
+ if (
364
+ eventName !== "subagents:created" &&
365
+ eventName !== "subagents:started" &&
366
+ eventName !== "subagents:completed" &&
367
+ eventName !== "subagents:failed"
368
+ )
369
+ return;
227
370
  if (!raw || typeof raw !== "object") return;
228
371
  const data = raw as Record<string, unknown>;
229
372
  const agentId = typeof data.id === "string" ? data.id : typeof data.agentId === "string" ? data.agentId : undefined;
230
373
  if (!agentId) return;
231
374
 
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
- }
375
+ const rawOwner = data.owner;
376
+ if (!rawOwner || typeof rawOwner !== "object" || Array.isArray(rawOwner)) return;
377
+ const ownerRecord = rawOwner as Record<string, unknown>;
378
+ if (
379
+ ownerRecord.extension !== "pi-workflows" ||
380
+ typeof ownerRecord.runId !== "string" ||
381
+ typeof ownerRecord.nodeId !== "string" ||
382
+ typeof ownerRecord.attemptId !== "string"
383
+ )
384
+ return;
385
+ const owner: WorkflowOwner = {
386
+ extension: "pi-workflows",
387
+ runId: ownerRecord.runId,
388
+ nodeId: ownerRecord.nodeId,
389
+ attemptId: ownerRecord.attemptId,
390
+ };
391
+ const state = this.runs.get(owner.runId);
392
+ if (state?.run.status !== "running" || state.lifecycleSuspended || state.controller.signal.aborted) return;
393
+ const callNodeId = callIndexFromOwnerNode(owner.nodeId);
394
+ if (callNodeId === undefined) return;
395
+
396
+ // Validate ownership and the current attempt before accepting any identity
397
+ // from the host. A delayed lifecycle fact from an older attempt must not
398
+ // overwrite the tier identity of the current attempt.
399
+ const spawnKey = `${owner.runId}/${owner.nodeId}/${owner.attemptId}`;
400
+ const pending = state.pendingSpawns.get(spawnKey);
401
+ const waiter = state.agentWaiters.get(agentId);
402
+ const waiterMatches = Boolean(
403
+ waiter &&
404
+ !waiter.settled &&
405
+ waiter.executionGeneration === state.executionGeneration &&
406
+ owner.nodeId === `call-${waiter.callIndex}` &&
407
+ owner.attemptId === `attempt-${waiter.generation}`,
408
+ );
409
+ const pendingMatches = Boolean(
410
+ pending && pending.executionGeneration === state.executionGeneration && sameWorkflowOwner(pending.owner, owner),
411
+ );
412
+ if (!waiterMatches && !pendingMatches) return;
413
+ // Top-level journal attempts carry the full run/node/attempt identity while
414
+ // lifecycle owners carry the short attempt token. Nested calls have no
415
+ // top-level attempt fact and are fenced by their pending/waiter owner.
416
+ const currentAttemptId = state.run.attemptIds[callNodeId];
417
+ if (currentAttemptId !== undefined && currentAttemptId !== `${owner.runId}/${callNodeId}/${owner.attemptId}`)
418
+ return;
419
+
420
+ if (updateCallTierIdentity(state, callNodeId, data.tier)) {
421
+ this.persistCallTierIdentity(state, callNodeId);
280
422
  }
423
+ // Created/started lifecycle facts carry managed identity before the RPC
424
+ // reply is necessarily available. They are observation-only here; terminal
425
+ // ownership is handled below once a completion/failure fact arrives.
426
+ if (eventName !== "subagents:completed" && eventName !== "subagents:failed") return;
427
+
428
+ const lifecycleStatus =
429
+ eventName === "subagents:completed"
430
+ ? "completed"
431
+ : data.status === "stopped" || data.status === "interrupted"
432
+ ? "stopped"
433
+ : "failed";
434
+ const tokenCount =
435
+ typeof data.tokenCount === "number"
436
+ ? data.tokenCount
437
+ : data.tokens &&
438
+ typeof data.tokens === "object" &&
439
+ typeof (data.tokens as Record<string, unknown>).total === "number"
440
+ ? ((data.tokens as Record<string, unknown>).total as number)
441
+ : undefined;
442
+ const delivery: AgentTerminalDelivery = {
443
+ response: {
444
+ id: agentId,
445
+ terminal: {
446
+ status: lifecycleStatus,
447
+ ...(typeof data.result === "string" ? { result: data.result } : {}),
448
+ ...(typeof data.error === "string" ? { error: data.error } : {}),
449
+ ...(typeof data.outputFile === "string" ? { outputFile: data.outputFile } : {}),
450
+ ...(tokenCount === undefined ? {} : { tokenCount }),
451
+ compactionCount: typeof data.compactionCount === "number" ? data.compactionCount : 0,
452
+ completedAt: typeof data.completedAt === "number" ? data.completedAt : Date.now(),
453
+ },
454
+ },
455
+ ...(tokenCount === undefined
456
+ ? {}
457
+ : { usage: { input: 0, output: tokenCount, total: tokenCount, cost: 0, cacheRead: 0, cacheWrite: 0 } }),
458
+ ...(Array.isArray(data.history) ? { history: [...data.history] } : {}),
459
+ };
460
+
461
+ if (waiterMatches && waiter) {
462
+ this.resolveAgentWaiter(state, agentId, waiter, delivery);
463
+ return;
464
+ }
465
+
466
+ // spawn-managed can emit a terminal lifecycle event synchronously before its
467
+ // reply continuation registers the agent-id waiter. Buffer only an owner
468
+ // that is currently awaiting that exact reply in this execution generation.
469
+ if (!pendingMatches || state.bufferedTerminals.has(spawnKey) || state.bufferedTerminals.size >= MAX_AGENTS_PER_RUN)
470
+ return;
471
+ state.bufferedTerminals.set(spawnKey, { ...delivery, owner, executionGeneration: state.executionGeneration });
472
+ }
473
+
474
+ /** Persist a host-normalized tier learned before the managed reply arrives. */
475
+ private persistCallTierIdentity(state: ScriptRunState, nodeId: string): void {
476
+ const attemptId = state.run.attemptIds[nodeId];
477
+ const identity = state.callTiers.get(nodeId);
478
+ // Nested workflow frames are in-memory only; only a top-level call has a
479
+ // durable attempt owner and can safely receive a journal transition.
480
+ if (!attemptId || identity?.tier === undefined) return;
481
+ this.persist(state.run.runId, `call-tier:${nodeId}`, () =>
482
+ this.write({
483
+ kind: "call_transition",
484
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
485
+ runId: state.run.runId,
486
+ nodeId,
487
+ status: "running",
488
+ tier: identity.tier,
489
+ attemptId,
490
+ owner: { extension: "pi-workflows", runId: state.run.runId, nodeId, attemptId },
491
+ timestamp: Date.now(),
492
+ }),
493
+ );
494
+ }
495
+
496
+ private resolveAgentWaiter(
497
+ state: ScriptRunState,
498
+ agentId: string,
499
+ waiter: AgentLifecycleWaiter,
500
+ delivery: AgentTerminalDelivery,
501
+ ): void {
502
+ if (waiter.settled) return;
503
+ waiter.settled = true;
504
+ state.agentWaiters.delete(agentId);
505
+ if (delivery.usage) waiter.onUsage?.(delivery.usage);
506
+ if (delivery.history) waiter.onHistory?.(delivery.history);
507
+ waiter.resolve(delivery.response);
281
508
  }
282
509
 
283
510
  dispose(): void {
511
+ if (this.disposed) return;
512
+ this.disposed = true;
513
+ this.disposeController.abort();
284
514
  this.unsubscribeLifecycle();
285
515
  this.quarantinedRunIds.clear();
286
516
  for (const state of this.runs.values()) {
@@ -289,25 +519,25 @@ export class WorkflowEngine {
289
519
  }
290
520
  this.rejectWaiters(new WorkflowWaitAbortedError());
291
521
  this.runs.clear();
292
- for (const timer of this.providerResumeTimers.values()) clearTimeout(timer);
293
- this.providerResumeTimers.clear();
522
+ this.clearJournalRetries();
294
523
  this.branchQuiescePromise = undefined;
295
524
  }
296
525
 
297
526
  suspendLifecycle(): void {
298
- if (this.lifecyclePaused) return;
527
+ if (this.disposed || this.lifecyclePaused) return;
299
528
  this.lifecyclePaused = true;
300
529
  this.unsubscribeLifecycle();
301
530
  }
302
531
 
303
532
  resumeLifecycle(): void {
304
- if (!this.lifecyclePaused) return;
533
+ if (this.disposed || !this.lifecyclePaused) return;
305
534
  this.lifecyclePaused = false;
306
535
  this.unsubscribeLifecycle = this.attachLifecycle();
307
536
  }
308
537
 
309
538
  /** Stop owned agents while Pi replaces the active session-tree branch. */
310
539
  async quiesceForBranchChange(): Promise<WorkflowQuiesceResult> {
540
+ if (this.disposed) return { settled: true, pending: [] };
311
541
  if (this.branchQuiescePromise) return this.branchQuiescePromise;
312
542
  this.clearJournalRetries();
313
543
 
@@ -329,6 +559,7 @@ export class WorkflowEngine {
329
559
  });
330
560
  targets.set(state.run.runId, { agentIds, owners });
331
561
  state.lifecycleSuspended = true;
562
+ state.bufferedTerminals.clear();
332
563
  state.controller.abort(new DOMException("Workflow branch change", "AbortError"));
333
564
  }
334
565
 
@@ -389,23 +620,50 @@ export class WorkflowEngine {
389
620
  * the body it executes).
390
621
  */
391
622
  async start(script: string, options: ScriptStartOptions = {}): Promise<ScriptStartResult> {
623
+ if (this.disposed) throw new WorkflowEngineDisposedError();
624
+ const protocol = await this.awaitProtocol();
625
+ if (this.disposed) throw new WorkflowEngineDisposedError();
392
626
  const { meta } = parseWorkflowScript(script);
393
627
  const now = Date.now();
394
628
  const runId = randomUUID();
395
629
  const scriptHash = hashScript(script);
396
630
  const toolset = options.toolset;
631
+ const shippedScript = options.shippedScript === true ? true : undefined;
632
+ const durableArgs = cloneWorkflowArgs(options.args);
633
+ const runtimeArgs = cloneWorkflowArgs(durableArgs);
634
+ const journalArgs = cloneWorkflowArgs(durableArgs);
635
+ const frozenArgsPresent = durableArgs !== undefined;
636
+ const frozenMaxAgents = options.maxAgents === undefined ? undefined : normalizeMaxAgents(options.maxAgents);
637
+ const frozenConcurrency = options.concurrency === undefined ? undefined : normalizeConcurrency(options.concurrency);
638
+ const frozenAgentRetries =
639
+ options.agentRetries === undefined ? undefined : normalizeAgentRetries(options.agentRetries);
640
+ const frozenTokenBudget = options.tokenBudget === undefined ? undefined : normalizeTokenBudget(options.tokenBudget);
641
+ const frozenAgentTimeoutMs =
642
+ options.agentTimeoutMs === undefined ? undefined : normalizeAgentTimeout(options.agentTimeoutMs);
643
+ const startOptions: ScriptStartOptions = {
644
+ ...options,
645
+ ...(runtimeArgs === undefined ? {} : { args: runtimeArgs }),
646
+ ...(frozenMaxAgents === undefined ? {} : { maxAgents: frozenMaxAgents }),
647
+ ...(frozenConcurrency === undefined ? {} : { concurrency: frozenConcurrency }),
648
+ ...(frozenAgentRetries === undefined ? {} : { agentRetries: frozenAgentRetries }),
649
+ ...(frozenTokenBudget === undefined ? {} : { tokenBudget: frozenTokenBudget }),
650
+ ...(frozenAgentTimeoutMs === undefined ? {} : { agentTimeoutMs: frozenAgentTimeoutMs }),
651
+ };
397
652
  const run: ScriptRun = {
398
653
  runId,
399
654
  schemaVersion: JOURNAL_SCHEMA_VERSION,
400
655
  script,
401
656
  scriptHash,
402
657
  meta,
658
+ ...(durableArgs === undefined ? {} : { args: durableArgs }),
659
+ frozenArgsPresent,
660
+ ...(shippedScript === undefined ? {} : { shippedScript }),
403
661
  ...(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 } : {}),
662
+ ...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
663
+ ...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
664
+ ...(frozenAgentRetries !== undefined ? { frozenAgentRetries } : {}),
665
+ ...(frozenTokenBudget !== undefined ? { frozenTokenBudget } : {}),
666
+ ...(frozenAgentTimeoutMs !== undefined ? { frozenAgentTimeoutMs } : {}),
409
667
  ...(options.excludeTools ? { frozenExcludeTools: [...options.excludeTools] } : {}),
410
668
  status: "pending",
411
669
  callStatus: {},
@@ -413,6 +671,8 @@ export class WorkflowEngine {
413
671
  attempts: {},
414
672
  attemptIds: {},
415
673
  callResults: {},
674
+ callTiers: {},
675
+ workflowResultGenerations: {},
416
676
  compactions: {},
417
677
  startedAt: now,
418
678
  updatedAt: now,
@@ -424,29 +684,35 @@ export class WorkflowEngine {
424
684
  script,
425
685
  scriptHash,
426
686
  meta,
687
+ ...(journalArgs === undefined ? {} : { args: journalArgs }),
688
+ frozenArgsPresent,
689
+ ...(shippedScript === undefined ? {} : { shippedScript }),
427
690
  ...(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 } : {}),
691
+ ...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
692
+ ...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
693
+ ...(frozenAgentRetries !== undefined ? { frozenAgentRetries } : {}),
694
+ ...(frozenTokenBudget !== undefined ? { frozenTokenBudget } : {}),
695
+ ...(frozenAgentTimeoutMs !== undefined ? { frozenAgentTimeoutMs } : {}),
433
696
  ...(options.excludeTools ? { frozenExcludeTools: [...options.excludeTools] } : {}),
434
697
  timestamp: now,
435
698
  });
436
699
  const state: ScriptRunState = {
437
700
  run,
438
701
  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 } : {}),
702
+ ...(frozenTokenBudget !== undefined ? { tokenBudget: frozenTokenBudget } : {}),
703
+ ...(frozenMaxAgents !== undefined ? { maxAgents: frozenMaxAgents } : {}),
704
+ ...(frozenConcurrency !== undefined ? { concurrency: frozenConcurrency } : {}),
705
+ ...(frozenAgentRetries !== undefined ? { agentRetries: frozenAgentRetries } : {}),
706
+ ...(frozenAgentTimeoutMs !== undefined ? { agentTimeoutMs: frozenAgentTimeoutMs } : {}),
444
707
  ...(options.toolset ? { toolset: options.toolset } : {}),
708
+ ...(shippedScript === undefined ? {} : { shippedScript }),
445
709
  ...(options.excludeTools ? { excludeTools: [...options.excludeTools] } : {}),
446
710
  },
447
711
  controller: new AbortController(),
448
712
  generations: new Map(),
713
+ callTiers: new Map(Object.entries(run.callTiers ?? {})),
449
714
  agentWaiters: new Map(),
715
+ bufferedTerminals: new Map(),
450
716
  pendingSpawns: new Map(),
451
717
  pendingJournal: new Set(),
452
718
  lifecycleSuspended: false,
@@ -455,7 +721,7 @@ export class WorkflowEngine {
455
721
  this.runs.set(runId, state);
456
722
  this.setWorkflowStatus(run, "running");
457
723
  state.executionGeneration += 1;
458
- state.execution = this.execute(runId, script, options, undefined, state.executionGeneration);
724
+ state.execution = this.execute(runId, script, startOptions, undefined, state.executionGeneration, protocol);
459
725
  // A run-fatal abort/stop already journals its terminal state; never let a
460
726
  // leftover rejection become an unhandled promise rejection.
461
727
  void state.execution.catch(() => {});
@@ -468,7 +734,7 @@ export class WorkflowEngine {
468
734
  runId,
469
735
  status: settled.status,
470
736
  background: false,
471
- ...(scriptResult !== undefined ? { result: String(scriptResult) } : {}),
737
+ ...(scriptResult !== undefined ? { result: renderWorkflowValue(scriptResult) } : {}),
472
738
  ...(settled.error ? { error: settled.error } : {}),
473
739
  };
474
740
  } catch (error: unknown) {
@@ -490,6 +756,9 @@ export class WorkflowEngine {
490
756
  options: ScriptStartOptions = {},
491
757
  replacementScript?: string,
492
758
  ): Promise<ScriptStartResult | undefined> {
759
+ if (this.disposed) throw new WorkflowEngineDisposedError();
760
+ const protocol = await this.awaitProtocol();
761
+ if (this.disposed) throw new WorkflowEngineDisposedError();
493
762
  const state = this.runs.get(runId);
494
763
  if (!state) return undefined;
495
764
  if (state.lifecycleSuspended) return undefined;
@@ -521,6 +790,9 @@ export class WorkflowEngine {
521
790
  }),
522
791
  );
523
792
  }
793
+ // Arguments are frozen in every schema-v4 run_created fact. Resume never accepts
794
+ // replacement args and never backfills the pre-schema-v4 run_args contract.
795
+ const resumeArgs = cloneWorkflowArgs(run.args);
524
796
  const resumeJournal = buildResumeJournal(entries);
525
797
  // Seed generation bases from journaled attemptIds so a live re-dispatch of a
526
798
  // previously-journaled call never reuses a spawnKey (A4).
@@ -529,22 +801,32 @@ export class WorkflowEngine {
529
801
  }
530
802
  this.setWorkflowStatus(run, "running");
531
803
  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.
804
+ // Freeze semantics: original run parameters and args win over new options.
805
+ // Only signal/confirm/background/loadSavedWorkflow/mainModel are per-resume context;
806
+ // input/budget/scale/toolset are frozen from the original start.
535
807
  const frozen = state.frozenOptions;
536
808
  const resumeOptions: ScriptStartOptions = {
537
809
  ...options,
810
+ args: resumeArgs,
538
811
  tokenBudget: frozen.tokenBudget,
539
812
  maxAgents: frozen.maxAgents,
540
813
  concurrency: frozen.concurrency,
541
814
  agentRetries: frozen.agentRetries,
542
815
  agentTimeoutMs: frozen.agentTimeoutMs,
543
816
  toolset: frozen.toolset,
817
+ shippedScript: frozen.shippedScript,
544
818
  excludeTools: frozen.excludeTools ? [...frozen.excludeTools] : undefined,
545
819
  };
820
+ state.bufferedTerminals.clear();
546
821
  state.executionGeneration += 1;
547
- state.execution = this.execute(runId, run.script, resumeOptions, resumeJournal, state.executionGeneration);
822
+ state.execution = this.execute(
823
+ runId,
824
+ run.script,
825
+ resumeOptions,
826
+ resumeJournal,
827
+ state.executionGeneration,
828
+ protocol,
829
+ );
548
830
  void state.execution.catch(() => {});
549
831
  if (options.background) return { runId, status: run.status, background: true };
550
832
  try {
@@ -561,6 +843,10 @@ export class WorkflowEngine {
561
843
  runId?: string,
562
844
  entries?: readonly SessionEntryLike[],
563
845
  ): Promise<{ action: string; runs?: unknown[]; run?: unknown }> {
846
+ if (this.disposed) {
847
+ if (action === "list") return { action, runs: [] };
848
+ throw new WorkflowEngineDisposedError();
849
+ }
564
850
  if (action === "list") return { action, runs: this.list() };
565
851
  if (!runId) throw new Error(`${action} requires runId`);
566
852
  const state = this.runs.get(runId);
@@ -624,6 +910,7 @@ export class WorkflowEngine {
624
910
  if (!state) throw new Error(`workflow run not found: ${runId}`);
625
911
  if (isTerminalWorkflow(state.run.status) || state.run.status === "paused" || state.run.status === "interrupted")
626
912
  return state.run;
913
+ if (signal?.aborted) throw new WorkflowWaitAbortedError();
627
914
  const all =
628
915
  this.waiters.get(runId) ??
629
916
  new Set<{
@@ -689,7 +976,9 @@ export class WorkflowEngine {
689
976
  options: ScriptStartOptions,
690
977
  resumeJournal?: Map<string, JournalEntry>,
691
978
  executionGeneration?: number,
979
+ protocol?: ManagedProtocolCheck,
692
980
  ): Promise<WorkflowRunResult<unknown>> {
981
+ if (this.disposed) throw new WorkflowEngineDisposedError();
693
982
  const state = this.runs.get(runId);
694
983
  if (!state) throw new Error(`workflow run not found: ${runId}`);
695
984
  const generation = executionGeneration ?? state.executionGeneration;
@@ -705,6 +994,12 @@ export class WorkflowEngine {
705
994
  args: options.args,
706
995
  agent: runner,
707
996
  mainModel: options.mainModel,
997
+ ...(protocol === undefined
998
+ ? {}
999
+ : {
1000
+ routingPolicy: protocol.routingPolicy,
1001
+ routingPolicyFingerprint: protocol.routingPolicyFingerprint,
1002
+ }),
708
1003
  concurrency: options.concurrency,
709
1004
  agentRetries: options.agentRetries,
710
1005
  tokenBudget: options.tokenBudget,
@@ -712,24 +1007,43 @@ export class WorkflowEngine {
712
1007
  signal: state.controller.signal,
713
1008
  maxAgents: options.maxAgents,
714
1009
  runId,
1010
+ executionNonce: `${generation}-${randomUUID()}`,
715
1011
  resumeJournal,
716
1012
  toolset: options.toolset,
1013
+ shippedScript: options.shippedScript,
717
1014
  excludeTools: options.excludeTools,
718
1015
  confirm: options.confirm,
719
1016
  loadSavedWorkflow: options.loadSavedWorkflow,
720
1017
  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.
1018
+ if (state.executionGeneration !== generation) return;
1019
+ // Nested workflow frames keep their own lifecycle visibility, but their
1020
+ // individual call facts are not top-level replay keys. The parent
1021
+ // workflow_result fact below owns the nested replay boundary.
724
1022
  if (entry.runId === undefined || entry.runId === runId) this.journalCallResult(runId, entry);
725
1023
  },
726
- onRuntimeEvent: () => {},
1024
+ onWorkflowJournal: (entry) => {
1025
+ if (state.executionGeneration !== generation) return;
1026
+ this.journalWorkflowResult(runId, entry);
1027
+ },
1028
+ onRuntimeEvent: (event) => {
1029
+ if (state.executionGeneration !== generation) return;
1030
+ try {
1031
+ this.events.emit("pi-workflows:runtime", { runId, event: cloneRuntimeEvent(event) });
1032
+ } catch {
1033
+ // Cross-extension observers cannot change workflow execution.
1034
+ }
1035
+ try {
1036
+ this.onRuntimeEvent?.(runId, cloneRuntimeEvent(event));
1037
+ } catch {
1038
+ // UI observers are diagnostic surfaces and are independently contained.
1039
+ }
1040
+ },
727
1041
  });
728
1042
  const live = this.runs.get(runId);
729
- if (live && state.executionGeneration === generation) live.result = result.result;
730
1043
  const run = live?.run;
731
1044
  if (run && state.executionGeneration === generation && !isTerminalWorkflow(run.status)) {
732
- this.setWorkflowStatus(run, "completed");
1045
+ this.setWorkflowStatus(run, "completed", undefined, undefined, result.result);
1046
+ live.result = result.result;
733
1047
  }
734
1048
  return result;
735
1049
  } catch (error: unknown) {
@@ -758,11 +1072,12 @@ export class WorkflowEngine {
758
1072
 
759
1073
  /**
760
1074
  * 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.
1075
+ * spawnKey: `${runId}/call-${callIndex}/attempt-${generation}`. Top-level calls
1076
+ * use numeric call indexes; nested workflow frames use a generation/nonce
1077
+ * namespace so a changed child cannot reuse a stale managed identity.
764
1078
  */
765
1079
  private async dispatchAgent(runId: string, prompt: string, runOptions: AgentRunOptions): Promise<unknown> {
1080
+ if (this.disposed) throw new WorkflowEngineDisposedError();
766
1081
  const state = this.runs.get(runId);
767
1082
  if (!state) throw new Error(`workflow run not found: ${runId}`);
768
1083
  if (state.lifecycleSuspended || state.controller.signal.aborted) {
@@ -773,13 +1088,19 @@ export class WorkflowEngine {
773
1088
  const run = state.run;
774
1089
  const executionGeneration = state.executionGeneration;
775
1090
  const callIndex = runOptions.callIndex ?? state.generations.size;
1091
+ const frameRunId = runOptions.runId ?? runId;
1092
+ const nested = frameRunId !== runId;
1093
+ // Top-level calls use their numeric lexical index. Nested frames use a
1094
+ // run-local namespace so child call 0 can never collide with the parent's
1095
+ // call 0 or with a sibling nested workflow. The managed owner remains the
1096
+ // top-level run: nested workflow results are journaled as one parent fact.
1097
+ const nodeId = nested ? `${frameRunId}:${callIndex}` : String(callIndex);
776
1098
  // 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);
1099
+ // spawnKey = `${runId}/call-${nodeId}/attempt-${generation}`
1100
+ // The journal node identity is the bare call index for top-level calls;
1101
+ // nested identities are in-memory only and are never persisted as agent
1102
+ // facts.
1103
+ const spawnNodeId = `call-${nodeId}`;
783
1104
  const base = state.generations.get(nodeId) ?? 0;
784
1105
  const generation = base + (runOptions.attempt ?? 1);
785
1106
  state.generations.set(nodeId, generation);
@@ -787,39 +1108,53 @@ export class WorkflowEngine {
787
1108
  const attemptId = `${runId}/${nodeId}/attempt-${generation}`;
788
1109
 
789
1110
  const type = runOptions.agentType ?? "general-purpose";
790
- const tier =
791
- runOptions.tier === "small" || runOptions.tier === "medium" || runOptions.tier === "large"
792
- ? runOptions.tier
793
- : undefined;
1111
+ const tier = runOptions.tier;
1112
+ if (tier !== undefined && !isManagedAgentTier(tier)) {
1113
+ throw new WorkflowError(
1114
+ `agent tier must be a non-empty, whitespace-free key (received ${String(tier)})`,
1115
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1116
+ { recoverable: false },
1117
+ );
1118
+ }
1119
+ // This dispatch is authoritative for the current script revision. Do not
1120
+ // carry an identity from the previous script: deleting `tier` must clear
1121
+ // the recorded one instead of silently preserving the old route.
1122
+ const tierIdentity: CallTierIdentity = tier === undefined ? {} : { tier };
1123
+ state.callTiers.set(nodeId, tierIdentity);
1124
+ run.callTiers[nodeId] = tierIdentity;
794
1125
  const task: DispatchTask = {
795
1126
  subagent_type: type,
796
1127
  prompt,
797
1128
  description: runOptions.label ?? `workflow call ${callIndex}`,
798
1129
  ...(tier === undefined ? {} : { tier }),
799
- ...(runOptions.model === undefined ? {} : { model: runOptions.model }),
800
1130
  ...(runOptions.thread === undefined ? {} : { thread: runOptions.thread }),
801
1131
  ...(runOptions.toolset === undefined ? {} : { toolset: runOptions.toolset }),
802
1132
  ...(runOptions.excludeTools === undefined ? {} : { excludeTools: runOptions.excludeTools }),
803
1133
  ...(runOptions.isolation === undefined ? {} : { isolation: runOptions.isolation }),
804
1134
  };
805
1135
 
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();
1136
+ // Journal the attempt rotation only for top-level calls. A nested frame is
1137
+ // represented by its parent workflow_result replay boundary instead of
1138
+ // leaking child call facts into the parent's numeric call namespace.
1139
+ if (!nested) {
1140
+ this.persist(runId, `call-attempt:${nodeId}`, () =>
1141
+ this.write({
1142
+ kind: "call_attempt",
1143
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1144
+ runId,
1145
+ nodeId,
1146
+ attemptId,
1147
+ generation,
1148
+ ...(tierIdentity.tier === undefined ? {} : { tier: tierIdentity.tier }),
1149
+ owner: { extension: "pi-workflows", runId, nodeId, attemptId },
1150
+ timestamp: Date.now(),
1151
+ }),
1152
+ );
1153
+ run.callStatus[nodeId] = "running";
1154
+ run.attempts[nodeId] = generation;
1155
+ run.attemptIds[nodeId] = attemptId;
1156
+ run.updatedAt = Date.now();
1157
+ }
823
1158
 
824
1159
  // Abort linkage: an external signal (pause/stop/Esc) cancels the wait and
825
1160
  // stops the owned agent. Track the spawn key before the RPC reply arrives so
@@ -832,9 +1167,9 @@ export class WorkflowEngine {
832
1167
  nodeId: spawnNodeId,
833
1168
  attemptId: spawnAttemptId,
834
1169
  };
835
- const pendingKey = `${nodeId}\u0000${generation}`;
836
- state.pendingSpawns.set(pendingKey, { spawnKey, owner });
1170
+ state.pendingSpawns.set(spawnKey, { spawnKey, owner, executionGeneration });
837
1171
  const reconcilePending = () => {
1172
+ state.bufferedTerminals.delete(spawnKey);
838
1173
  const reconciliation = this.client.reconcileManaged?.(spawnKey, owner);
839
1174
  void reconciliation?.catch(() => {});
840
1175
  };
@@ -845,10 +1180,10 @@ export class WorkflowEngine {
845
1180
  let pendingId = "";
846
1181
  let response: ManagedSpawnResponse | string;
847
1182
  try {
848
- response = await this.client.spawn(task, runId, spawnNodeId, spawnAttemptId, undefined, externalSignal);
1183
+ response = await this.client.spawn(task, runId, spawnNodeId, spawnAttemptId, externalSignal);
849
1184
  } finally {
850
1185
  externalSignal.removeEventListener("abort", onPendingAbort);
851
- state.pendingSpawns.delete(pendingKey);
1186
+ state.pendingSpawns.delete(spawnKey);
852
1187
  }
853
1188
  const responseId = typeof response === "string" ? response : response.id;
854
1189
  if (
@@ -856,6 +1191,7 @@ export class WorkflowEngine {
856
1191
  state.controller.signal.aborted ||
857
1192
  externalSignal.aborted
858
1193
  ) {
1194
+ state.bufferedTerminals.delete(spawnKey);
859
1195
  await this.client
860
1196
  .stopOwned?.(responseId, { extension: "pi-workflows", runId, nodeId: spawnNodeId, attemptId: spawnAttemptId })
861
1197
  .catch(() => {});
@@ -865,9 +1201,18 @@ export class WorkflowEngine {
865
1201
  { recoverable: true },
866
1202
  );
867
1203
  }
1204
+ if (typeof response !== "string" && updateCallTierIdentity(state, nodeId, response.tier)) {
1205
+ this.persistCallTierIdentity(state, nodeId);
1206
+ }
1207
+ // Report the tier the host actually selected back to the runtime, which
1208
+ // journals it: a call that named none still resolved to something, and the
1209
+ // run's own record of what ran should say what.
1210
+ const resolvedTierIdentity = state.callTiers.get(nodeId);
1211
+ if (resolvedTierIdentity?.tier !== undefined) runOptions.onTierResolved?.(resolvedTierIdentity.tier);
868
1212
  if (typeof response === "string") {
869
1213
  pendingId = response;
870
1214
  } else if (response.terminal) {
1215
+ state.bufferedTerminals.delete(spawnKey);
871
1216
  // The spawn response already carries a terminal snapshot (fast completion
872
1217
  // or interrupted replay). The runtime journals successful calls itself
873
1218
  // (onAgentJournal); a terminal snapshot that is NOT a success is
@@ -891,9 +1236,10 @@ export class WorkflowEngine {
891
1236
  terminal.error,
892
1237
  terminal.compactionCount,
893
1238
  terminal.tokenCount,
1239
+ resolvedTierIdentity?.tier ?? tier,
894
1240
  );
895
1241
  result.attemptId = attemptId;
896
- this.commitCallTerminal(runId, nodeId, result);
1242
+ if (!nested) this.commitCallTerminal(runId, nodeId, result);
897
1243
  if (status === "failed" && terminal.error) {
898
1244
  throw wrapError(new Error(terminal.error), { agentLabel: runOptions.label });
899
1245
  }
@@ -908,6 +1254,7 @@ export class WorkflowEngine {
908
1254
  if (waiter && !waiter.settled) {
909
1255
  waiter.settled = true;
910
1256
  state.agentWaiters.delete(pendingId);
1257
+ state.bufferedTerminals.delete(spawnKey);
911
1258
  void this.client
912
1259
  .stopOwned?.(pendingId, {
913
1260
  extension: "pi-workflows",
@@ -920,10 +1267,11 @@ export class WorkflowEngine {
920
1267
  }
921
1268
  };
922
1269
 
923
- // Wait for the terminal lifecycle event.
1270
+ // Wait for the terminal lifecycle event, consuming a matching terminal that
1271
+ // arrived while the managed-spawn reply was still pending.
924
1272
  const terminal = await new Promise<ManagedSpawnResponse>((resolve, reject) => {
925
- const waiter = {
926
- callIndex: String(callIndex),
1273
+ const waiter: AgentLifecycleWaiter = {
1274
+ callIndex: nodeId,
927
1275
  generation,
928
1276
  executionGeneration,
929
1277
  onUsage: runOptions.onUsage,
@@ -934,7 +1282,24 @@ export class WorkflowEngine {
934
1282
  };
935
1283
  state.agentWaiters.set(pendingId, waiter);
936
1284
  externalSignal.addEventListener("abort", waitAbort, { once: true });
937
- if (externalSignal.aborted) waitAbort();
1285
+ if (externalSignal.aborted) {
1286
+ waitAbort();
1287
+ return;
1288
+ }
1289
+
1290
+ const buffered = state.bufferedTerminals.get(spawnKey);
1291
+ if (!buffered) return;
1292
+ state.bufferedTerminals.delete(spawnKey);
1293
+ if (
1294
+ buffered.executionGeneration !== executionGeneration ||
1295
+ state.executionGeneration !== executionGeneration ||
1296
+ state.lifecycleSuspended ||
1297
+ state.controller.signal.aborted ||
1298
+ buffered.response.id !== pendingId ||
1299
+ !sameWorkflowOwner(buffered.owner, owner)
1300
+ )
1301
+ return;
1302
+ this.resolveAgentWaiter(state, pendingId, waiter, buffered);
938
1303
  }).finally(() => {
939
1304
  externalSignal.removeEventListener("abort", waitAbort);
940
1305
  });
@@ -956,13 +1321,16 @@ export class WorkflowEngine {
956
1321
  snapshot.error,
957
1322
  snapshot.compactionCount,
958
1323
  snapshot.tokenCount,
1324
+ state.callTiers.get(nodeId)?.tier ?? tier,
959
1325
  );
960
1326
  result.attemptId = attemptId;
961
- this.commitCallTerminal(runId, nodeId, result);
1327
+ if (!nested) this.commitCallTerminal(runId, nodeId, result);
962
1328
  if (status === "failed" && snapshot.error) {
963
1329
  throw wrapError(new Error(snapshot.error), { agentLabel: runOptions.label });
964
1330
  }
965
1331
  }
1332
+ const completedTierIdentity = state.callTiers.get(nodeId);
1333
+ if (completedTierIdentity?.tier !== undefined) runOptions.onTierResolved?.(completedTierIdentity.tier);
966
1334
  return snapshot.result ?? "";
967
1335
  }
968
1336
 
@@ -972,9 +1340,12 @@ export class WorkflowEngine {
972
1340
  const run = state.run;
973
1341
  const nodeId = String(entry.index);
974
1342
  const result = entry.result;
1343
+ const callTierIdentity: CallTierIdentity =
1344
+ entry.tier === undefined ? (run.callTiers[nodeId] ?? {}) : { tier: entry.tier };
975
1345
  const callResult: CallResult = {
976
1346
  status: "completed",
977
1347
  ...(typeof result === "string" && result.length === 0 ? {} : { result }),
1348
+ ...callTierIdentity,
978
1349
  compactionCount: 0,
979
1350
  updatedAt: Date.now(),
980
1351
  };
@@ -996,10 +1367,47 @@ export class WorkflowEngine {
996
1367
  };
997
1368
  this.persist(runId, `call-result:${nodeId}`, () => this.write(event));
998
1369
  run.callResults[nodeId] = callResult;
1370
+ run.callTiers[nodeId] = callResult.tier === undefined ? {} : { tier: callResult.tier };
999
1371
  run.callStatus[nodeId] = "completed";
1000
1372
  run.updatedAt = Date.now();
1001
1373
  }
1002
1374
 
1375
+ private journalWorkflowResult(runId: string, entry: JournalEntry): void {
1376
+ const state = this.runs.get(runId);
1377
+ if (!state) return;
1378
+ const nodeId = String(entry.index);
1379
+ const result: CallResult = {
1380
+ status: "completed",
1381
+ ...(entry.result === undefined ? {} : { result: entry.result }),
1382
+ ...(entry.tier === undefined ? {} : { tier: entry.tier }),
1383
+ compactionCount: 0,
1384
+ updatedAt: Date.now(),
1385
+ };
1386
+ const event: JournalEvent = {
1387
+ kind: "workflow_result",
1388
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1389
+ runId,
1390
+ nodeId,
1391
+ result,
1392
+ generation: entry.generation ?? 1,
1393
+ ...(entry.agentCount === undefined ? {} : { agentCount: entry.agentCount }),
1394
+ callHash: entry.hash,
1395
+ ...(entry.storeDelta !== undefined && Object.keys(entry.storeDelta).length > 0
1396
+ ? { storeDelta: entry.storeDelta }
1397
+ : {}),
1398
+ timestamp: Date.now(),
1399
+ };
1400
+ // The parent cannot become completed until this replay boundary is durable;
1401
+ // otherwise a transient append retry would arrive after the terminal
1402
+ // transition and be discarded as stale during restore.
1403
+ this.appendRequired(event);
1404
+ state.run.workflowResultGenerations[nodeId] = event.generation;
1405
+ state.run.callResults[nodeId] = result;
1406
+ state.run.callTiers[nodeId] = result.tier === undefined ? {} : { tier: result.tier };
1407
+ state.run.callStatus[nodeId] = "completed";
1408
+ state.run.updatedAt = Date.now();
1409
+ }
1410
+
1003
1411
  private commitCallTerminal(runId: string, nodeId: string, result: CallResult): void {
1004
1412
  const state = this.runs.get(runId);
1005
1413
  if (!state) return;
@@ -1017,6 +1425,7 @@ export class WorkflowEngine {
1017
1425
  };
1018
1426
  this.persist(runId, `call-terminal:${nodeId}`, () => this.write(event));
1019
1427
  run.callResults[nodeId] = result;
1428
+ run.callTiers[nodeId] = result.tier === undefined ? {} : { tier: result.tier };
1020
1429
  run.callStatus[nodeId] = result.status;
1021
1430
  if (result.agentId) run.agentIds[nodeId] = result.agentId;
1022
1431
  run.compactions[nodeId] = Math.max(run.compactions[nodeId] ?? 0, result.compactionCount);
@@ -1031,6 +1440,7 @@ export class WorkflowEngine {
1031
1440
  void reconciliation?.catch(() => {});
1032
1441
  }
1033
1442
  state.pendingSpawns.clear();
1443
+ state.bufferedTerminals.clear();
1034
1444
  }
1035
1445
 
1036
1446
  private async quiescePendingSpawns(state: ScriptRunState): Promise<WorkflowQuiesceResult> {
@@ -1093,6 +1503,8 @@ export class WorkflowEngine {
1093
1503
  undefined,
1094
1504
  "workflow stopped",
1095
1505
  run.compactions[nodeId] ?? 0,
1506
+ undefined,
1507
+ state.callTiers.get(nodeId)?.tier,
1096
1508
  );
1097
1509
  result.attemptId = run.attemptIds[nodeId];
1098
1510
  terminalResults.push({
@@ -1183,7 +1595,7 @@ export class WorkflowEngine {
1183
1595
  this.journal.append(event);
1184
1596
  } catch (error: unknown) {
1185
1597
  throw new WorkflowError(
1186
- `Could not persist workflow start: ${error instanceof Error ? error.message : String(error)}`,
1598
+ `Could not persist workflow ${event.kind}: ${error instanceof Error ? error.message : String(error)}`,
1187
1599
  WorkflowErrorCode.PERSISTENCE_ERROR,
1188
1600
  { recoverable: true, details: error },
1189
1601
  );
@@ -1247,11 +1659,19 @@ export class WorkflowEngine {
1247
1659
  this.providerResumeTimers.delete(runId);
1248
1660
  const state = this.runs.get(runId);
1249
1661
  if (state?.run.status !== "paused") return;
1250
- void this.resume(runId, this.readEntries(), { background: true }).catch((error: unknown) => {
1251
- console.warn(
1252
- `[pi-workflows] automatic provider-limit resume failed: ${error instanceof Error ? error.message : String(error)}`,
1253
- );
1254
- });
1662
+ void Promise.resolve()
1663
+ .then(() => {
1664
+ const current = this.runs.get(runId);
1665
+ if (current?.run.status !== "paused") return;
1666
+ // resume() is the single authorization gate and captures the fresh
1667
+ // routing-policy fingerprint for this execution.
1668
+ return this.resume(runId, this.readEntries(), { background: true });
1669
+ })
1670
+ .catch((error: unknown) => {
1671
+ console.warn(
1672
+ `[pi-workflows] automatic provider-limit resume failed: ${error instanceof Error ? error.message : String(error)}`,
1673
+ );
1674
+ });
1255
1675
  }, delay);
1256
1676
  timer.unref?.();
1257
1677
  this.providerResumeTimers.set(runId, timer);
@@ -1271,8 +1691,13 @@ export class WorkflowEngine {
1271
1691
  status: ScriptRun["status"],
1272
1692
  error?: string,
1273
1693
  terminalIntent?: "stop" | "failure",
1694
+ finalResult?: unknown,
1274
1695
  ): void {
1275
1696
  if (isTerminalWorkflow(run.status) && !isTerminalWorkflow(status)) return;
1697
+ // Validate and isolate terminal values before best-effort persistence or any
1698
+ // status mutation. A bad result must still be able to transition the run to failed.
1699
+ const durableFinalResult = finalResult === undefined ? undefined : cloneWorkflowResult(finalResult);
1700
+ const runFinalResult = finalResult === undefined ? undefined : cloneWorkflowResult(finalResult);
1276
1701
  const timestamp = Date.now();
1277
1702
  this.persist(run.runId, `transition:${status}`, () =>
1278
1703
  this.write({
@@ -1281,15 +1706,18 @@ export class WorkflowEngine {
1281
1706
  runId: run.runId,
1282
1707
  status,
1283
1708
  ...(error !== undefined ? { error } : {}),
1709
+ ...(durableFinalResult !== undefined ? { finalResult: durableFinalResult } : {}),
1284
1710
  ...(terminalIntent !== undefined ? { terminalIntent } : {}),
1285
1711
  timestamp,
1286
1712
  }),
1287
1713
  );
1288
1714
  run.status = status;
1289
1715
  if (error !== undefined) run.error = error;
1716
+ if (runFinalResult !== undefined) run.finalResult = runFinalResult;
1290
1717
  if (terminalIntent !== undefined) run.terminalIntent = terminalIntent;
1291
1718
  run.updatedAt = timestamp;
1292
1719
  if (isTerminalWorkflow(status)) {
1720
+ this.runs.get(run.runId)?.bufferedTerminals.clear();
1293
1721
  this.settleWaiters(run.runId, run);
1294
1722
  this.onRunSettled?.();
1295
1723
  }
@@ -1298,6 +1726,7 @@ export class WorkflowEngine {
1298
1726
  // ── restore / summary ──────────────────────────────────────────────────────
1299
1727
 
1300
1728
  restore(entries: readonly SessionEntryLike[], branchGeneration = 0): void {
1729
+ if (this.disposed) return;
1301
1730
  // A branch replay is a replacement, not a merge. Runs absent from the new
1302
1731
  // branch must not remain controllable or append facts into it.
1303
1732
  this.clearJournalRetries();
@@ -1321,11 +1750,14 @@ export class WorkflowEngine {
1321
1750
  ...(run.frozenAgentRetries !== undefined ? { agentRetries: run.frozenAgentRetries } : {}),
1322
1751
  ...(run.frozenAgentTimeoutMs !== undefined ? { agentTimeoutMs: run.frozenAgentTimeoutMs } : {}),
1323
1752
  ...(run.toolset ? { toolset: run.toolset } : {}),
1753
+ ...(run.shippedScript === undefined ? {} : { shippedScript: run.shippedScript }),
1324
1754
  ...(run.frozenExcludeTools ? { excludeTools: [...run.frozenExcludeTools] } : {}),
1325
1755
  },
1326
1756
  controller: new AbortController(),
1327
1757
  generations: new Map(),
1758
+ callTiers: new Map(Object.entries(run.callTiers ?? {})),
1328
1759
  agentWaiters: new Map(),
1760
+ bufferedTerminals: new Map(),
1329
1761
  pendingSpawns: new Map(),
1330
1762
  pendingJournal: new Set(),
1331
1763
  lifecycleSuspended: false,
@@ -1374,6 +1806,7 @@ export class WorkflowEngine {
1374
1806
  startedAt: run.startedAt,
1375
1807
  updatedAt: run.updatedAt,
1376
1808
  ...(run.error ? { error: run.error.slice(0, 2_000) } : {}),
1809
+ ...(run.finalResult !== undefined ? { resultPreview: renderWorkflowValue(run.finalResult).slice(0, 8_000) } : {}),
1377
1810
  };
1378
1811
  if (!detailed) return base;
1379
1812
  const calls = Object.entries(run.callStatus)
@@ -1398,6 +1831,13 @@ function hashScript(script: string): string {
1398
1831
  return createHash("sha256").update(script).digest("hex");
1399
1832
  }
1400
1833
 
1834
+ export class WorkflowEngineDisposedError extends Error {
1835
+ constructor() {
1836
+ super("Workflow engine is disposed");
1837
+ this.name = "WorkflowEngineDisposedError";
1838
+ }
1839
+ }
1840
+
1401
1841
  export class WorkflowWaitAbortedError extends Error {
1402
1842
  constructor() {
1403
1843
  super("workflow wait aborted");