@signalridge/pi-workflows 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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";
@@ -85,6 +88,8 @@ export interface ScriptStartOptions {
85
88
  concurrency?: number;
86
89
  agentRetries?: number;
87
90
  tokenBudget?: number | null;
91
+ /** True for a script this package ships; see WorkflowRunOptions.shippedScript. */
92
+ shippedScript?: boolean;
88
93
  agentTimeoutMs?: number | null;
89
94
  toolset?: string;
90
95
  excludeTools?: string[];
@@ -100,7 +105,13 @@ export interface ScriptStartOptions {
100
105
  timeoutMs?: number;
101
106
  },
102
107
  ) => Promise<unknown>;
103
- 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;
104
115
  mainModel?: string;
105
116
  }
106
117
 
@@ -140,7 +151,14 @@ export interface ScriptRunState {
140
151
  /** Frozen run parameters — resume reuses the original values, ignoring new budget/toolset overrides. */
141
152
  frozenOptions: Pick<
142
153
  ScriptStartOptions,
143
- "tokenBudget" | "maxAgents" | "concurrency" | "agentRetries" | "agentTimeoutMs" | "toolset" | "excludeTools"
154
+ | "tokenBudget"
155
+ | "maxAgents"
156
+ | "concurrency"
157
+ | "agentRetries"
158
+ | "agentTimeoutMs"
159
+ | "toolset"
160
+ | "shippedScript"
161
+ | "excludeTools"
144
162
  >;
145
163
  /** Abort controller for this run's script execution. */
146
164
  controller: AbortController;
@@ -152,6 +170,8 @@ export interface ScriptRunState {
152
170
  result?: unknown;
153
171
  /** Per-call-index generation base for A4 spawnKey rotation (seeded from journal). */
154
172
  generations: Map<string, number>;
173
+ /** Resolved tier identity for live calls, including stopped terminal results. */
174
+ callTiers: Map<string, CallTierIdentity>;
155
175
  /** Live dispatch waiters keyed by agent id. */
156
176
  agentWaiters: Map<string, AgentLifecycleWaiter>;
157
177
  /** Owner-validated terminal events emitted before their spawn reply/waiter. */
@@ -172,6 +192,19 @@ function managedTerminalStatus(snapshot: { status: string }): "completed" | "fai
172
192
  return snapshot.status === "completed" ? "completed" : snapshot.status === "failed" ? "failed" : "stopped";
173
193
  }
174
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
+
175
208
  function renderWorkflowValue(value: unknown): string {
176
209
  if (typeof value === "string") return value;
177
210
  try {
@@ -220,10 +253,12 @@ function resultFromLifecycle(
220
253
  error: string | undefined,
221
254
  compactionCount: number,
222
255
  tokenCount?: number,
256
+ tier?: string,
223
257
  ): CallResult {
224
258
  return {
225
259
  status,
226
260
  ...(agentId ? { agentId } : {}),
261
+ ...(tier === undefined ? {} : { tier }),
227
262
  ...(result !== undefined && result.length > 0 ? { result } : {}),
228
263
  ...(error !== undefined && error.length > 0 ? { error } : {}),
229
264
  ...(tokenCount !== undefined ? { tokenCount } : {}),
@@ -259,6 +294,9 @@ export class WorkflowEngine {
259
294
  Set<{ resolve: (run: ScriptRun) => void; reject: (e: unknown) => void; settled: boolean; cleanup: () => void }>
260
295
  >();
261
296
  private unsubscribeLifecycle: () => void;
297
+ private readonly protocolGate: () => Promise<ManagedProtocolCheck>;
298
+ private readonly disposeController = new AbortController();
299
+ private disposed = false;
262
300
  private lifecyclePaused = false;
263
301
  private branchQuiescePromise: Promise<WorkflowQuiesceResult> | undefined;
264
302
  private recoveryBranchGeneration = 0;
@@ -273,10 +311,37 @@ export class WorkflowEngine {
273
311
  private readonly client: ManagedSpawnClient,
274
312
  private readonly journal: JournalWriter,
275
313
  private readonly readEntries: () => readonly SessionEntryLike[] = () => [],
314
+ protocolGate?: () => Promise<ManagedProtocolCheck>,
276
315
  ) {
316
+ this.protocolGate =
317
+ protocolGate ??
318
+ (() => this.client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable")));
277
319
  this.unsubscribeLifecycle = this.attachLifecycle();
278
320
  }
279
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
+
280
345
  private attachLifecycle(): () => void {
281
346
  const unsubs = [
282
347
  "subagents:created",
@@ -295,7 +360,13 @@ export class WorkflowEngine {
295
360
  }
296
361
 
297
362
  private onLifecycle(eventName: string, raw: unknown): void {
298
- if (eventName !== "subagents:completed" && eventName !== "subagents:failed") return;
363
+ if (
364
+ eventName !== "subagents:created" &&
365
+ eventName !== "subagents:started" &&
366
+ eventName !== "subagents:completed" &&
367
+ eventName !== "subagents:failed"
368
+ )
369
+ return;
299
370
  if (!raw || typeof raw !== "object") return;
300
371
  const data = raw as Record<string, unknown>;
301
372
  const agentId = typeof data.id === "string" ? data.id : typeof data.agentId === "string" ? data.agentId : undefined;
@@ -319,6 +390,40 @@ export class WorkflowEngine {
319
390
  };
320
391
  const state = this.runs.get(owner.runId);
321
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);
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;
322
427
 
323
428
  const lifecycleStatus =
324
429
  eventName === "subagents:completed"
@@ -353,15 +458,7 @@ export class WorkflowEngine {
353
458
  ...(Array.isArray(data.history) ? { history: [...data.history] } : {}),
354
459
  };
355
460
 
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;
461
+ if (waiterMatches && waiter) {
365
462
  this.resolveAgentWaiter(state, agentId, waiter, delivery);
366
463
  return;
367
464
  }
@@ -369,19 +466,33 @@ export class WorkflowEngine {
369
466
  // spawn-managed can emit a terminal lifecycle event synchronously before its
370
467
  // reply continuation registers the agent-id waiter. Buffer only an owner
371
468
  // 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
- )
469
+ if (!pendingMatches || state.bufferedTerminals.has(spawnKey) || state.bufferedTerminals.size >= MAX_AGENTS_PER_RUN)
381
470
  return;
382
471
  state.bufferedTerminals.set(spawnKey, { ...delivery, owner, executionGeneration: state.executionGeneration });
383
472
  }
384
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
+
385
496
  private resolveAgentWaiter(
386
497
  state: ScriptRunState,
387
498
  agentId: string,
@@ -397,6 +508,9 @@ export class WorkflowEngine {
397
508
  }
398
509
 
399
510
  dispose(): void {
511
+ if (this.disposed) return;
512
+ this.disposed = true;
513
+ this.disposeController.abort();
400
514
  this.unsubscribeLifecycle();
401
515
  this.quarantinedRunIds.clear();
402
516
  for (const state of this.runs.values()) {
@@ -405,25 +519,25 @@ export class WorkflowEngine {
405
519
  }
406
520
  this.rejectWaiters(new WorkflowWaitAbortedError());
407
521
  this.runs.clear();
408
- for (const timer of this.providerResumeTimers.values()) clearTimeout(timer);
409
- this.providerResumeTimers.clear();
522
+ this.clearJournalRetries();
410
523
  this.branchQuiescePromise = undefined;
411
524
  }
412
525
 
413
526
  suspendLifecycle(): void {
414
- if (this.lifecyclePaused) return;
527
+ if (this.disposed || this.lifecyclePaused) return;
415
528
  this.lifecyclePaused = true;
416
529
  this.unsubscribeLifecycle();
417
530
  }
418
531
 
419
532
  resumeLifecycle(): void {
420
- if (!this.lifecyclePaused) return;
533
+ if (this.disposed || !this.lifecyclePaused) return;
421
534
  this.lifecyclePaused = false;
422
535
  this.unsubscribeLifecycle = this.attachLifecycle();
423
536
  }
424
537
 
425
538
  /** Stop owned agents while Pi replaces the active session-tree branch. */
426
539
  async quiesceForBranchChange(): Promise<WorkflowQuiesceResult> {
540
+ if (this.disposed) return { settled: true, pending: [] };
427
541
  if (this.branchQuiescePromise) return this.branchQuiescePromise;
428
542
  this.clearJournalRetries();
429
543
 
@@ -506,11 +620,15 @@ export class WorkflowEngine {
506
620
  * the body it executes).
507
621
  */
508
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();
509
626
  const { meta } = parseWorkflowScript(script);
510
627
  const now = Date.now();
511
628
  const runId = randomUUID();
512
629
  const scriptHash = hashScript(script);
513
630
  const toolset = options.toolset;
631
+ const shippedScript = options.shippedScript === true ? true : undefined;
514
632
  const durableArgs = cloneWorkflowArgs(options.args);
515
633
  const runtimeArgs = cloneWorkflowArgs(durableArgs);
516
634
  const journalArgs = cloneWorkflowArgs(durableArgs);
@@ -539,6 +657,7 @@ export class WorkflowEngine {
539
657
  meta,
540
658
  ...(durableArgs === undefined ? {} : { args: durableArgs }),
541
659
  frozenArgsPresent,
660
+ ...(shippedScript === undefined ? {} : { shippedScript }),
542
661
  ...(toolset ? { toolset } : {}),
543
662
  ...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
544
663
  ...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
@@ -552,6 +671,7 @@ export class WorkflowEngine {
552
671
  attempts: {},
553
672
  attemptIds: {},
554
673
  callResults: {},
674
+ callTiers: {},
555
675
  workflowResultGenerations: {},
556
676
  compactions: {},
557
677
  startedAt: now,
@@ -566,6 +686,7 @@ export class WorkflowEngine {
566
686
  meta,
567
687
  ...(journalArgs === undefined ? {} : { args: journalArgs }),
568
688
  frozenArgsPresent,
689
+ ...(shippedScript === undefined ? {} : { shippedScript }),
569
690
  ...(toolset ? { toolset } : {}),
570
691
  ...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
571
692
  ...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
@@ -584,10 +705,12 @@ export class WorkflowEngine {
584
705
  ...(frozenAgentRetries !== undefined ? { agentRetries: frozenAgentRetries } : {}),
585
706
  ...(frozenAgentTimeoutMs !== undefined ? { agentTimeoutMs: frozenAgentTimeoutMs } : {}),
586
707
  ...(options.toolset ? { toolset: options.toolset } : {}),
708
+ ...(shippedScript === undefined ? {} : { shippedScript }),
587
709
  ...(options.excludeTools ? { excludeTools: [...options.excludeTools] } : {}),
588
710
  },
589
711
  controller: new AbortController(),
590
712
  generations: new Map(),
713
+ callTiers: new Map(Object.entries(run.callTiers ?? {})),
591
714
  agentWaiters: new Map(),
592
715
  bufferedTerminals: new Map(),
593
716
  pendingSpawns: new Map(),
@@ -598,7 +721,7 @@ export class WorkflowEngine {
598
721
  this.runs.set(runId, state);
599
722
  this.setWorkflowStatus(run, "running");
600
723
  state.executionGeneration += 1;
601
- state.execution = this.execute(runId, script, startOptions, undefined, state.executionGeneration);
724
+ state.execution = this.execute(runId, script, startOptions, undefined, state.executionGeneration, protocol);
602
725
  // A run-fatal abort/stop already journals its terminal state; never let a
603
726
  // leftover rejection become an unhandled promise rejection.
604
727
  void state.execution.catch(() => {});
@@ -633,6 +756,9 @@ export class WorkflowEngine {
633
756
  options: ScriptStartOptions = {},
634
757
  replacementScript?: string,
635
758
  ): Promise<ScriptStartResult | undefined> {
759
+ if (this.disposed) throw new WorkflowEngineDisposedError();
760
+ const protocol = await this.awaitProtocol();
761
+ if (this.disposed) throw new WorkflowEngineDisposedError();
636
762
  const state = this.runs.get(runId);
637
763
  if (!state) return undefined;
638
764
  if (state.lifecycleSuspended) return undefined;
@@ -664,26 +790,9 @@ export class WorkflowEngine {
664
790
  }),
665
791
  );
666
792
  }
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
- }
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);
687
796
  const resumeJournal = buildResumeJournal(entries);
688
797
  // Seed generation bases from journaled attemptIds so a live re-dispatch of a
689
798
  // previously-journaled call never reuses a spawnKey (A4).
@@ -705,11 +814,19 @@ export class WorkflowEngine {
705
814
  agentRetries: frozen.agentRetries,
706
815
  agentTimeoutMs: frozen.agentTimeoutMs,
707
816
  toolset: frozen.toolset,
817
+ shippedScript: frozen.shippedScript,
708
818
  excludeTools: frozen.excludeTools ? [...frozen.excludeTools] : undefined,
709
819
  };
710
820
  state.bufferedTerminals.clear();
711
821
  state.executionGeneration += 1;
712
- 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
+ );
713
830
  void state.execution.catch(() => {});
714
831
  if (options.background) return { runId, status: run.status, background: true };
715
832
  try {
@@ -726,6 +843,10 @@ export class WorkflowEngine {
726
843
  runId?: string,
727
844
  entries?: readonly SessionEntryLike[],
728
845
  ): Promise<{ action: string; runs?: unknown[]; run?: unknown }> {
846
+ if (this.disposed) {
847
+ if (action === "list") return { action, runs: [] };
848
+ throw new WorkflowEngineDisposedError();
849
+ }
729
850
  if (action === "list") return { action, runs: this.list() };
730
851
  if (!runId) throw new Error(`${action} requires runId`);
731
852
  const state = this.runs.get(runId);
@@ -855,7 +976,9 @@ export class WorkflowEngine {
855
976
  options: ScriptStartOptions,
856
977
  resumeJournal?: Map<string, JournalEntry>,
857
978
  executionGeneration?: number,
979
+ protocol?: ManagedProtocolCheck,
858
980
  ): Promise<WorkflowRunResult<unknown>> {
981
+ if (this.disposed) throw new WorkflowEngineDisposedError();
859
982
  const state = this.runs.get(runId);
860
983
  if (!state) throw new Error(`workflow run not found: ${runId}`);
861
984
  const generation = executionGeneration ?? state.executionGeneration;
@@ -871,6 +994,12 @@ export class WorkflowEngine {
871
994
  args: options.args,
872
995
  agent: runner,
873
996
  mainModel: options.mainModel,
997
+ ...(protocol === undefined
998
+ ? {}
999
+ : {
1000
+ routingPolicy: protocol.routingPolicy,
1001
+ routingPolicyFingerprint: protocol.routingPolicyFingerprint,
1002
+ }),
874
1003
  concurrency: options.concurrency,
875
1004
  agentRetries: options.agentRetries,
876
1005
  tokenBudget: options.tokenBudget,
@@ -881,6 +1010,7 @@ export class WorkflowEngine {
881
1010
  executionNonce: `${generation}-${randomUUID()}`,
882
1011
  resumeJournal,
883
1012
  toolset: options.toolset,
1013
+ shippedScript: options.shippedScript,
884
1014
  excludeTools: options.excludeTools,
885
1015
  confirm: options.confirm,
886
1016
  loadSavedWorkflow: options.loadSavedWorkflow,
@@ -947,6 +1077,7 @@ export class WorkflowEngine {
947
1077
  * namespace so a changed child cannot reuse a stale managed identity.
948
1078
  */
949
1079
  private async dispatchAgent(runId: string, prompt: string, runOptions: AgentRunOptions): Promise<unknown> {
1080
+ if (this.disposed) throw new WorkflowEngineDisposedError();
950
1081
  const state = this.runs.get(runId);
951
1082
  if (!state) throw new Error(`workflow run not found: ${runId}`);
952
1083
  if (state.lifecycleSuspended || state.controller.signal.aborted) {
@@ -977,16 +1108,25 @@ export class WorkflowEngine {
977
1108
  const attemptId = `${runId}/${nodeId}/attempt-${generation}`;
978
1109
 
979
1110
  const type = runOptions.agentType ?? "general-purpose";
980
- const tier =
981
- runOptions.tier === "small" || runOptions.tier === "medium" || runOptions.tier === "large"
982
- ? runOptions.tier
983
- : 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;
984
1125
  const task: DispatchTask = {
985
1126
  subagent_type: type,
986
1127
  prompt,
987
1128
  description: runOptions.label ?? `workflow call ${callIndex}`,
988
1129
  ...(tier === undefined ? {} : { tier }),
989
- ...(runOptions.model === undefined ? {} : { model: runOptions.model }),
990
1130
  ...(runOptions.thread === undefined ? {} : { thread: runOptions.thread }),
991
1131
  ...(runOptions.toolset === undefined ? {} : { toolset: runOptions.toolset }),
992
1132
  ...(runOptions.excludeTools === undefined ? {} : { excludeTools: runOptions.excludeTools }),
@@ -1005,6 +1145,7 @@ export class WorkflowEngine {
1005
1145
  nodeId,
1006
1146
  attemptId,
1007
1147
  generation,
1148
+ ...(tierIdentity.tier === undefined ? {} : { tier: tierIdentity.tier }),
1008
1149
  owner: { extension: "pi-workflows", runId, nodeId, attemptId },
1009
1150
  timestamp: Date.now(),
1010
1151
  }),
@@ -1039,7 +1180,7 @@ export class WorkflowEngine {
1039
1180
  let pendingId = "";
1040
1181
  let response: ManagedSpawnResponse | string;
1041
1182
  try {
1042
- response = await this.client.spawn(task, runId, spawnNodeId, spawnAttemptId, undefined, externalSignal);
1183
+ response = await this.client.spawn(task, runId, spawnNodeId, spawnAttemptId, externalSignal);
1043
1184
  } finally {
1044
1185
  externalSignal.removeEventListener("abort", onPendingAbort);
1045
1186
  state.pendingSpawns.delete(spawnKey);
@@ -1060,6 +1201,14 @@ export class WorkflowEngine {
1060
1201
  { recoverable: true },
1061
1202
  );
1062
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);
1063
1212
  if (typeof response === "string") {
1064
1213
  pendingId = response;
1065
1214
  } else if (response.terminal) {
@@ -1087,6 +1236,7 @@ export class WorkflowEngine {
1087
1236
  terminal.error,
1088
1237
  terminal.compactionCount,
1089
1238
  terminal.tokenCount,
1239
+ resolvedTierIdentity?.tier ?? tier,
1090
1240
  );
1091
1241
  result.attemptId = attemptId;
1092
1242
  if (!nested) this.commitCallTerminal(runId, nodeId, result);
@@ -1171,6 +1321,7 @@ export class WorkflowEngine {
1171
1321
  snapshot.error,
1172
1322
  snapshot.compactionCount,
1173
1323
  snapshot.tokenCount,
1324
+ state.callTiers.get(nodeId)?.tier ?? tier,
1174
1325
  );
1175
1326
  result.attemptId = attemptId;
1176
1327
  if (!nested) this.commitCallTerminal(runId, nodeId, result);
@@ -1178,6 +1329,8 @@ export class WorkflowEngine {
1178
1329
  throw wrapError(new Error(snapshot.error), { agentLabel: runOptions.label });
1179
1330
  }
1180
1331
  }
1332
+ const completedTierIdentity = state.callTiers.get(nodeId);
1333
+ if (completedTierIdentity?.tier !== undefined) runOptions.onTierResolved?.(completedTierIdentity.tier);
1181
1334
  return snapshot.result ?? "";
1182
1335
  }
1183
1336
 
@@ -1187,9 +1340,12 @@ export class WorkflowEngine {
1187
1340
  const run = state.run;
1188
1341
  const nodeId = String(entry.index);
1189
1342
  const result = entry.result;
1343
+ const callTierIdentity: CallTierIdentity =
1344
+ entry.tier === undefined ? (run.callTiers[nodeId] ?? {}) : { tier: entry.tier };
1190
1345
  const callResult: CallResult = {
1191
1346
  status: "completed",
1192
1347
  ...(typeof result === "string" && result.length === 0 ? {} : { result }),
1348
+ ...callTierIdentity,
1193
1349
  compactionCount: 0,
1194
1350
  updatedAt: Date.now(),
1195
1351
  };
@@ -1211,6 +1367,7 @@ export class WorkflowEngine {
1211
1367
  };
1212
1368
  this.persist(runId, `call-result:${nodeId}`, () => this.write(event));
1213
1369
  run.callResults[nodeId] = callResult;
1370
+ run.callTiers[nodeId] = callResult.tier === undefined ? {} : { tier: callResult.tier };
1214
1371
  run.callStatus[nodeId] = "completed";
1215
1372
  run.updatedAt = Date.now();
1216
1373
  }
@@ -1222,6 +1379,7 @@ export class WorkflowEngine {
1222
1379
  const result: CallResult = {
1223
1380
  status: "completed",
1224
1381
  ...(entry.result === undefined ? {} : { result: entry.result }),
1382
+ ...(entry.tier === undefined ? {} : { tier: entry.tier }),
1225
1383
  compactionCount: 0,
1226
1384
  updatedAt: Date.now(),
1227
1385
  };
@@ -1245,6 +1403,7 @@ export class WorkflowEngine {
1245
1403
  this.appendRequired(event);
1246
1404
  state.run.workflowResultGenerations[nodeId] = event.generation;
1247
1405
  state.run.callResults[nodeId] = result;
1406
+ state.run.callTiers[nodeId] = result.tier === undefined ? {} : { tier: result.tier };
1248
1407
  state.run.callStatus[nodeId] = "completed";
1249
1408
  state.run.updatedAt = Date.now();
1250
1409
  }
@@ -1266,6 +1425,7 @@ export class WorkflowEngine {
1266
1425
  };
1267
1426
  this.persist(runId, `call-terminal:${nodeId}`, () => this.write(event));
1268
1427
  run.callResults[nodeId] = result;
1428
+ run.callTiers[nodeId] = result.tier === undefined ? {} : { tier: result.tier };
1269
1429
  run.callStatus[nodeId] = result.status;
1270
1430
  if (result.agentId) run.agentIds[nodeId] = result.agentId;
1271
1431
  run.compactions[nodeId] = Math.max(run.compactions[nodeId] ?? 0, result.compactionCount);
@@ -1343,6 +1503,8 @@ export class WorkflowEngine {
1343
1503
  undefined,
1344
1504
  "workflow stopped",
1345
1505
  run.compactions[nodeId] ?? 0,
1506
+ undefined,
1507
+ state.callTiers.get(nodeId)?.tier,
1346
1508
  );
1347
1509
  result.attemptId = run.attemptIds[nodeId];
1348
1510
  terminalResults.push({
@@ -1497,11 +1659,19 @@ export class WorkflowEngine {
1497
1659
  this.providerResumeTimers.delete(runId);
1498
1660
  const state = this.runs.get(runId);
1499
1661
  if (state?.run.status !== "paused") return;
1500
- void this.resume(runId, this.readEntries(), { background: true }).catch((error: unknown) => {
1501
- console.warn(
1502
- `[pi-workflows] automatic provider-limit resume failed: ${error instanceof Error ? error.message : String(error)}`,
1503
- );
1504
- });
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
+ });
1505
1675
  }, delay);
1506
1676
  timer.unref?.();
1507
1677
  this.providerResumeTimers.set(runId, timer);
@@ -1556,6 +1726,7 @@ export class WorkflowEngine {
1556
1726
  // ── restore / summary ──────────────────────────────────────────────────────
1557
1727
 
1558
1728
  restore(entries: readonly SessionEntryLike[], branchGeneration = 0): void {
1729
+ if (this.disposed) return;
1559
1730
  // A branch replay is a replacement, not a merge. Runs absent from the new
1560
1731
  // branch must not remain controllable or append facts into it.
1561
1732
  this.clearJournalRetries();
@@ -1579,10 +1750,12 @@ export class WorkflowEngine {
1579
1750
  ...(run.frozenAgentRetries !== undefined ? { agentRetries: run.frozenAgentRetries } : {}),
1580
1751
  ...(run.frozenAgentTimeoutMs !== undefined ? { agentTimeoutMs: run.frozenAgentTimeoutMs } : {}),
1581
1752
  ...(run.toolset ? { toolset: run.toolset } : {}),
1753
+ ...(run.shippedScript === undefined ? {} : { shippedScript: run.shippedScript }),
1582
1754
  ...(run.frozenExcludeTools ? { excludeTools: [...run.frozenExcludeTools] } : {}),
1583
1755
  },
1584
1756
  controller: new AbortController(),
1585
1757
  generations: new Map(),
1758
+ callTiers: new Map(Object.entries(run.callTiers ?? {})),
1586
1759
  agentWaiters: new Map(),
1587
1760
  bufferedTerminals: new Map(),
1588
1761
  pendingSpawns: new Map(),
@@ -1658,6 +1831,13 @@ function hashScript(script: string): string {
1658
1831
  return createHash("sha256").update(script).digest("hex");
1659
1832
  }
1660
1833
 
1834
+ export class WorkflowEngineDisposedError extends Error {
1835
+ constructor() {
1836
+ super("Workflow engine is disposed");
1837
+ this.name = "WorkflowEngineDisposedError";
1838
+ }
1839
+ }
1840
+
1661
1841
  export class WorkflowWaitAbortedError extends Error {
1662
1842
  constructor() {
1663
1843
  super("workflow wait aborted");