@signalridge/pi-workflows 1.4.0 → 1.6.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";
@@ -58,6 +61,7 @@ import {
58
61
  type WorkflowRunResult,
59
62
  type WorkflowRuntimeEvent,
60
63
  } from "./runtime.js";
64
+ import type { WorkflowStrengthTable } from "./strengths.js";
61
65
 
62
66
  export const MAX_ATTEMPTS_PER_NODE = 3;
63
67
 
@@ -85,6 +89,19 @@ export interface ScriptStartOptions {
85
89
  concurrency?: number;
86
90
  agentRetries?: number;
87
91
  tokenBudget?: number | null;
92
+ /**
93
+ * The user's strength → Agent tier table; see WorkflowRunOptions.strengths.
94
+ *
95
+ * Deliberately absent from `frozenOptions`. The frozen set is the run's
96
+ * inputs — budget, scale, toolset — which a resume must not silently
97
+ * renegotiate. Routing is not an input: a resume already re-reads the host's
98
+ * tier catalogue, and a table frozen against a catalogue that has since moved
99
+ * would be the one thing in the run still pointing at the old world.
100
+ *
101
+ * Being unfrozen makes supplying it every resume's job, including the resumes
102
+ * the engine starts itself — see {@link WorkflowEngine.strengths}.
103
+ */
104
+ strengths?: WorkflowStrengthTable;
88
105
  agentTimeoutMs?: number | null;
89
106
  toolset?: string;
90
107
  excludeTools?: string[];
@@ -100,6 +117,7 @@ export interface ScriptStartOptions {
100
117
  timeoutMs?: number;
101
118
  },
102
119
  ) => Promise<unknown>;
120
+ /** Resolve a nested `workflow(name)` reference to the script it names. */
103
121
  loadSavedWorkflow?: (name: string) => string | undefined;
104
122
  mainModel?: string;
105
123
  }
@@ -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 } : {}),
@@ -254,11 +289,26 @@ export class WorkflowEngine {
254
289
  onRunSettled: (() => void) | undefined;
255
290
  /** Optional in-process observer for phase/task/quality runtime events. */
256
291
  onRuntimeEvent: ((runId: string, event: WorkflowRuntimeEvent) => void) | undefined;
292
+ /**
293
+ * The user's current strength → Agent tier table, read fresh per execution.
294
+ *
295
+ * A callback rather than a value because the table is deliberately unfrozen:
296
+ * "read fresh at each start and resume" has to hold for the resumes the
297
+ * engine starts on its own — `control("resume")` and the provider-limit
298
+ * retry — which have no caller to pass options for them. Without it those
299
+ * resume with no table at all, and since a call's identity keys on the tier
300
+ * it requested, every already-finished call would miss its journal entry and
301
+ * re-run untabled: the exact re-spend this table exists to prevent.
302
+ */
303
+ strengths: (() => WorkflowStrengthTable | undefined) | undefined;
257
304
  private readonly waiters = new Map<
258
305
  string,
259
306
  Set<{ resolve: (run: ScriptRun) => void; reject: (e: unknown) => void; settled: boolean; cleanup: () => void }>
260
307
  >();
261
308
  private unsubscribeLifecycle: () => void;
309
+ private readonly protocolGate: () => Promise<ManagedProtocolCheck>;
310
+ private readonly disposeController = new AbortController();
311
+ private disposed = false;
262
312
  private lifecyclePaused = false;
263
313
  private branchQuiescePromise: Promise<WorkflowQuiesceResult> | undefined;
264
314
  private recoveryBranchGeneration = 0;
@@ -273,10 +323,37 @@ export class WorkflowEngine {
273
323
  private readonly client: ManagedSpawnClient,
274
324
  private readonly journal: JournalWriter,
275
325
  private readonly readEntries: () => readonly SessionEntryLike[] = () => [],
326
+ protocolGate?: () => Promise<ManagedProtocolCheck>,
276
327
  ) {
328
+ this.protocolGate =
329
+ protocolGate ??
330
+ (() => this.client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable")));
277
331
  this.unsubscribeLifecycle = this.attachLifecycle();
278
332
  }
279
333
 
334
+ private async awaitProtocol(): Promise<ManagedProtocolCheck> {
335
+ if (this.disposed) throw new WorkflowEngineDisposedError();
336
+ let rejectDisposed: ((reason?: unknown) => void) | undefined;
337
+ const disposed = new Promise<never>((_, reject) => {
338
+ rejectDisposed = reject;
339
+ });
340
+ const onDisposed = () => rejectDisposed?.(new WorkflowEngineDisposedError());
341
+ this.disposeController.signal.addEventListener("abort", onDisposed, { once: true });
342
+ let result: ManagedProtocolCheck;
343
+ try {
344
+ result = await Promise.race([this.protocolGate(), disposed]);
345
+ } finally {
346
+ this.disposeController.signal.removeEventListener("abort", onDisposed);
347
+ }
348
+ if (this.disposed) throw new WorkflowEngineDisposedError();
349
+ return result;
350
+ }
351
+
352
+ /** Whether this engine can still accept new workflow executions. */
353
+ isDisposed(): boolean {
354
+ return this.disposed;
355
+ }
356
+
280
357
  private attachLifecycle(): () => void {
281
358
  const unsubs = [
282
359
  "subagents:created",
@@ -295,7 +372,13 @@ export class WorkflowEngine {
295
372
  }
296
373
 
297
374
  private onLifecycle(eventName: string, raw: unknown): void {
298
- if (eventName !== "subagents:completed" && eventName !== "subagents:failed") return;
375
+ if (
376
+ eventName !== "subagents:created" &&
377
+ eventName !== "subagents:started" &&
378
+ eventName !== "subagents:completed" &&
379
+ eventName !== "subagents:failed"
380
+ )
381
+ return;
299
382
  if (!raw || typeof raw !== "object") return;
300
383
  const data = raw as Record<string, unknown>;
301
384
  const agentId = typeof data.id === "string" ? data.id : typeof data.agentId === "string" ? data.agentId : undefined;
@@ -319,6 +402,40 @@ export class WorkflowEngine {
319
402
  };
320
403
  const state = this.runs.get(owner.runId);
321
404
  if (state?.run.status !== "running" || state.lifecycleSuspended || state.controller.signal.aborted) return;
405
+ const callNodeId = callIndexFromOwnerNode(owner.nodeId);
406
+ if (callNodeId === undefined) return;
407
+
408
+ // Validate ownership and the current attempt before accepting any identity
409
+ // from the host. A delayed lifecycle fact from an older attempt must not
410
+ // overwrite the tier identity of the current attempt.
411
+ const spawnKey = `${owner.runId}/${owner.nodeId}/${owner.attemptId}`;
412
+ const pending = state.pendingSpawns.get(spawnKey);
413
+ const waiter = state.agentWaiters.get(agentId);
414
+ const waiterMatches = Boolean(
415
+ waiter &&
416
+ !waiter.settled &&
417
+ waiter.executionGeneration === state.executionGeneration &&
418
+ owner.nodeId === `call-${waiter.callIndex}` &&
419
+ owner.attemptId === `attempt-${waiter.generation}`,
420
+ );
421
+ const pendingMatches = Boolean(
422
+ pending && pending.executionGeneration === state.executionGeneration && sameWorkflowOwner(pending.owner, owner),
423
+ );
424
+ if (!waiterMatches && !pendingMatches) return;
425
+ // Top-level journal attempts carry the full run/node/attempt identity while
426
+ // lifecycle owners carry the short attempt token. Nested calls have no
427
+ // top-level attempt fact and are fenced by their pending/waiter owner.
428
+ const currentAttemptId = state.run.attemptIds[callNodeId];
429
+ if (currentAttemptId !== undefined && currentAttemptId !== `${owner.runId}/${callNodeId}/${owner.attemptId}`)
430
+ return;
431
+
432
+ if (updateCallTierIdentity(state, callNodeId, data.tier)) {
433
+ this.persistCallTierIdentity(state, callNodeId);
434
+ }
435
+ // Created/started lifecycle facts carry managed identity before the RPC
436
+ // reply is necessarily available. They are observation-only here; terminal
437
+ // ownership is handled below once a completion/failure fact arrives.
438
+ if (eventName !== "subagents:completed" && eventName !== "subagents:failed") return;
322
439
 
323
440
  const lifecycleStatus =
324
441
  eventName === "subagents:completed"
@@ -353,15 +470,7 @@ export class WorkflowEngine {
353
470
  ...(Array.isArray(data.history) ? { history: [...data.history] } : {}),
354
471
  };
355
472
 
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;
473
+ if (waiterMatches && waiter) {
365
474
  this.resolveAgentWaiter(state, agentId, waiter, delivery);
366
475
  return;
367
476
  }
@@ -369,19 +478,33 @@ export class WorkflowEngine {
369
478
  // spawn-managed can emit a terminal lifecycle event synchronously before its
370
479
  // reply continuation registers the agent-id waiter. Buffer only an owner
371
480
  // 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
- )
481
+ if (!pendingMatches || state.bufferedTerminals.has(spawnKey) || state.bufferedTerminals.size >= MAX_AGENTS_PER_RUN)
381
482
  return;
382
483
  state.bufferedTerminals.set(spawnKey, { ...delivery, owner, executionGeneration: state.executionGeneration });
383
484
  }
384
485
 
486
+ /** Persist a host-normalized tier learned before the managed reply arrives. */
487
+ private persistCallTierIdentity(state: ScriptRunState, nodeId: string): void {
488
+ const attemptId = state.run.attemptIds[nodeId];
489
+ const identity = state.callTiers.get(nodeId);
490
+ // Nested workflow frames are in-memory only; only a top-level call has a
491
+ // durable attempt owner and can safely receive a journal transition.
492
+ if (!attemptId || identity?.tier === undefined) return;
493
+ this.persist(state.run.runId, `call-tier:${nodeId}`, () =>
494
+ this.write({
495
+ kind: "call_transition",
496
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
497
+ runId: state.run.runId,
498
+ nodeId,
499
+ status: "running",
500
+ tier: identity.tier,
501
+ attemptId,
502
+ owner: { extension: "pi-workflows", runId: state.run.runId, nodeId, attemptId },
503
+ timestamp: Date.now(),
504
+ }),
505
+ );
506
+ }
507
+
385
508
  private resolveAgentWaiter(
386
509
  state: ScriptRunState,
387
510
  agentId: string,
@@ -397,6 +520,9 @@ export class WorkflowEngine {
397
520
  }
398
521
 
399
522
  dispose(): void {
523
+ if (this.disposed) return;
524
+ this.disposed = true;
525
+ this.disposeController.abort();
400
526
  this.unsubscribeLifecycle();
401
527
  this.quarantinedRunIds.clear();
402
528
  for (const state of this.runs.values()) {
@@ -405,25 +531,25 @@ export class WorkflowEngine {
405
531
  }
406
532
  this.rejectWaiters(new WorkflowWaitAbortedError());
407
533
  this.runs.clear();
408
- for (const timer of this.providerResumeTimers.values()) clearTimeout(timer);
409
- this.providerResumeTimers.clear();
534
+ this.clearJournalRetries();
410
535
  this.branchQuiescePromise = undefined;
411
536
  }
412
537
 
413
538
  suspendLifecycle(): void {
414
- if (this.lifecyclePaused) return;
539
+ if (this.disposed || this.lifecyclePaused) return;
415
540
  this.lifecyclePaused = true;
416
541
  this.unsubscribeLifecycle();
417
542
  }
418
543
 
419
544
  resumeLifecycle(): void {
420
- if (!this.lifecyclePaused) return;
545
+ if (this.disposed || !this.lifecyclePaused) return;
421
546
  this.lifecyclePaused = false;
422
547
  this.unsubscribeLifecycle = this.attachLifecycle();
423
548
  }
424
549
 
425
550
  /** Stop owned agents while Pi replaces the active session-tree branch. */
426
551
  async quiesceForBranchChange(): Promise<WorkflowQuiesceResult> {
552
+ if (this.disposed) return { settled: true, pending: [] };
427
553
  if (this.branchQuiescePromise) return this.branchQuiescePromise;
428
554
  this.clearJournalRetries();
429
555
 
@@ -506,6 +632,9 @@ export class WorkflowEngine {
506
632
  * the body it executes).
507
633
  */
508
634
  async start(script: string, options: ScriptStartOptions = {}): Promise<ScriptStartResult> {
635
+ if (this.disposed) throw new WorkflowEngineDisposedError();
636
+ const protocol = await this.awaitProtocol();
637
+ if (this.disposed) throw new WorkflowEngineDisposedError();
509
638
  const { meta } = parseWorkflowScript(script);
510
639
  const now = Date.now();
511
640
  const runId = randomUUID();
@@ -552,6 +681,7 @@ export class WorkflowEngine {
552
681
  attempts: {},
553
682
  attemptIds: {},
554
683
  callResults: {},
684
+ callTiers: {},
555
685
  workflowResultGenerations: {},
556
686
  compactions: {},
557
687
  startedAt: now,
@@ -588,6 +718,7 @@ export class WorkflowEngine {
588
718
  },
589
719
  controller: new AbortController(),
590
720
  generations: new Map(),
721
+ callTiers: new Map(Object.entries(run.callTiers ?? {})),
591
722
  agentWaiters: new Map(),
592
723
  bufferedTerminals: new Map(),
593
724
  pendingSpawns: new Map(),
@@ -598,7 +729,7 @@ export class WorkflowEngine {
598
729
  this.runs.set(runId, state);
599
730
  this.setWorkflowStatus(run, "running");
600
731
  state.executionGeneration += 1;
601
- state.execution = this.execute(runId, script, startOptions, undefined, state.executionGeneration);
732
+ state.execution = this.execute(runId, script, startOptions, undefined, state.executionGeneration, protocol);
602
733
  // A run-fatal abort/stop already journals its terminal state; never let a
603
734
  // leftover rejection become an unhandled promise rejection.
604
735
  void state.execution.catch(() => {});
@@ -633,6 +764,9 @@ export class WorkflowEngine {
633
764
  options: ScriptStartOptions = {},
634
765
  replacementScript?: string,
635
766
  ): Promise<ScriptStartResult | undefined> {
767
+ if (this.disposed) throw new WorkflowEngineDisposedError();
768
+ const protocol = await this.awaitProtocol();
769
+ if (this.disposed) throw new WorkflowEngineDisposedError();
636
770
  const state = this.runs.get(runId);
637
771
  if (!state) return undefined;
638
772
  if (state.lifecycleSuspended) return undefined;
@@ -664,26 +798,9 @@ export class WorkflowEngine {
664
798
  }),
665
799
  );
666
800
  }
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
- }
801
+ // Arguments are frozen in every schema-v4 run_created fact. Resume never accepts
802
+ // replacement args and never backfills the pre-schema-v4 run_args contract.
803
+ const resumeArgs = cloneWorkflowArgs(run.args);
687
804
  const resumeJournal = buildResumeJournal(entries);
688
805
  // Seed generation bases from journaled attemptIds so a live re-dispatch of a
689
806
  // previously-journaled call never reuses a spawnKey (A4).
@@ -693,11 +810,15 @@ export class WorkflowEngine {
693
810
  this.setWorkflowStatus(run, "running");
694
811
  state.controller = new AbortController();
695
812
  // 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.
813
+ // Only signal/confirm/background/loadSavedWorkflow/mainModel/strengths are
814
+ // per-resume context; input/budget/scale/toolset are frozen from the
815
+ // original start.
698
816
  const frozen = state.frozenOptions;
699
817
  const resumeOptions: ScriptStartOptions = {
700
818
  ...options,
819
+ // `??`, not an overwrite: a caller that passed a table meant it, and only
820
+ // a caller that passed none is asking the engine to read the current one.
821
+ strengths: options.strengths ?? this.strengths?.(),
701
822
  args: resumeArgs,
702
823
  tokenBudget: frozen.tokenBudget,
703
824
  maxAgents: frozen.maxAgents,
@@ -709,7 +830,14 @@ export class WorkflowEngine {
709
830
  };
710
831
  state.bufferedTerminals.clear();
711
832
  state.executionGeneration += 1;
712
- state.execution = this.execute(runId, run.script, resumeOptions, resumeJournal, state.executionGeneration);
833
+ state.execution = this.execute(
834
+ runId,
835
+ run.script,
836
+ resumeOptions,
837
+ resumeJournal,
838
+ state.executionGeneration,
839
+ protocol,
840
+ );
713
841
  void state.execution.catch(() => {});
714
842
  if (options.background) return { runId, status: run.status, background: true };
715
843
  try {
@@ -726,6 +854,10 @@ export class WorkflowEngine {
726
854
  runId?: string,
727
855
  entries?: readonly SessionEntryLike[],
728
856
  ): Promise<{ action: string; runs?: unknown[]; run?: unknown }> {
857
+ if (this.disposed) {
858
+ if (action === "list") return { action, runs: [] };
859
+ throw new WorkflowEngineDisposedError();
860
+ }
729
861
  if (action === "list") return { action, runs: this.list() };
730
862
  if (!runId) throw new Error(`${action} requires runId`);
731
863
  const state = this.runs.get(runId);
@@ -855,7 +987,9 @@ export class WorkflowEngine {
855
987
  options: ScriptStartOptions,
856
988
  resumeJournal?: Map<string, JournalEntry>,
857
989
  executionGeneration?: number,
990
+ protocol?: ManagedProtocolCheck,
858
991
  ): Promise<WorkflowRunResult<unknown>> {
992
+ if (this.disposed) throw new WorkflowEngineDisposedError();
859
993
  const state = this.runs.get(runId);
860
994
  if (!state) throw new Error(`workflow run not found: ${runId}`);
861
995
  const generation = executionGeneration ?? state.executionGeneration;
@@ -871,6 +1005,12 @@ export class WorkflowEngine {
871
1005
  args: options.args,
872
1006
  agent: runner,
873
1007
  mainModel: options.mainModel,
1008
+ ...(protocol === undefined
1009
+ ? {}
1010
+ : {
1011
+ routingPolicy: protocol.routingPolicy,
1012
+ routingPolicyFingerprint: protocol.routingPolicyFingerprint,
1013
+ }),
874
1014
  concurrency: options.concurrency,
875
1015
  agentRetries: options.agentRetries,
876
1016
  tokenBudget: options.tokenBudget,
@@ -881,6 +1021,7 @@ export class WorkflowEngine {
881
1021
  executionNonce: `${generation}-${randomUUID()}`,
882
1022
  resumeJournal,
883
1023
  toolset: options.toolset,
1024
+ strengths: options.strengths,
884
1025
  excludeTools: options.excludeTools,
885
1026
  confirm: options.confirm,
886
1027
  loadSavedWorkflow: options.loadSavedWorkflow,
@@ -947,6 +1088,7 @@ export class WorkflowEngine {
947
1088
  * namespace so a changed child cannot reuse a stale managed identity.
948
1089
  */
949
1090
  private async dispatchAgent(runId: string, prompt: string, runOptions: AgentRunOptions): Promise<unknown> {
1091
+ if (this.disposed) throw new WorkflowEngineDisposedError();
950
1092
  const state = this.runs.get(runId);
951
1093
  if (!state) throw new Error(`workflow run not found: ${runId}`);
952
1094
  if (state.lifecycleSuspended || state.controller.signal.aborted) {
@@ -977,16 +1119,25 @@ export class WorkflowEngine {
977
1119
  const attemptId = `${runId}/${nodeId}/attempt-${generation}`;
978
1120
 
979
1121
  const type = runOptions.agentType ?? "general-purpose";
980
- const tier =
981
- runOptions.tier === "small" || runOptions.tier === "medium" || runOptions.tier === "large"
982
- ? runOptions.tier
983
- : undefined;
1122
+ const tier = runOptions.tier;
1123
+ if (tier !== undefined && !isManagedAgentTier(tier)) {
1124
+ throw new WorkflowError(
1125
+ `agent tier must be a non-empty, whitespace-free key (received ${String(tier)})`,
1126
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1127
+ { recoverable: false },
1128
+ );
1129
+ }
1130
+ // This dispatch is authoritative for the current script revision. Do not
1131
+ // carry an identity from the previous script: deleting `tier` must clear
1132
+ // the recorded one instead of silently preserving the old route.
1133
+ const tierIdentity: CallTierIdentity = tier === undefined ? {} : { tier };
1134
+ state.callTiers.set(nodeId, tierIdentity);
1135
+ run.callTiers[nodeId] = tierIdentity;
984
1136
  const task: DispatchTask = {
985
1137
  subagent_type: type,
986
1138
  prompt,
987
1139
  description: runOptions.label ?? `workflow call ${callIndex}`,
988
1140
  ...(tier === undefined ? {} : { tier }),
989
- ...(runOptions.model === undefined ? {} : { model: runOptions.model }),
990
1141
  ...(runOptions.thread === undefined ? {} : { thread: runOptions.thread }),
991
1142
  ...(runOptions.toolset === undefined ? {} : { toolset: runOptions.toolset }),
992
1143
  ...(runOptions.excludeTools === undefined ? {} : { excludeTools: runOptions.excludeTools }),
@@ -1005,6 +1156,7 @@ export class WorkflowEngine {
1005
1156
  nodeId,
1006
1157
  attemptId,
1007
1158
  generation,
1159
+ ...(tierIdentity.tier === undefined ? {} : { tier: tierIdentity.tier }),
1008
1160
  owner: { extension: "pi-workflows", runId, nodeId, attemptId },
1009
1161
  timestamp: Date.now(),
1010
1162
  }),
@@ -1039,7 +1191,7 @@ export class WorkflowEngine {
1039
1191
  let pendingId = "";
1040
1192
  let response: ManagedSpawnResponse | string;
1041
1193
  try {
1042
- response = await this.client.spawn(task, runId, spawnNodeId, spawnAttemptId, undefined, externalSignal);
1194
+ response = await this.client.spawn(task, runId, spawnNodeId, spawnAttemptId, externalSignal);
1043
1195
  } finally {
1044
1196
  externalSignal.removeEventListener("abort", onPendingAbort);
1045
1197
  state.pendingSpawns.delete(spawnKey);
@@ -1060,6 +1212,14 @@ export class WorkflowEngine {
1060
1212
  { recoverable: true },
1061
1213
  );
1062
1214
  }
1215
+ if (typeof response !== "string" && updateCallTierIdentity(state, nodeId, response.tier)) {
1216
+ this.persistCallTierIdentity(state, nodeId);
1217
+ }
1218
+ // Report the tier the host actually selected back to the runtime, which
1219
+ // journals it: a call that named none still resolved to something, and the
1220
+ // run's own record of what ran should say what.
1221
+ const resolvedTierIdentity = state.callTiers.get(nodeId);
1222
+ if (resolvedTierIdentity?.tier !== undefined) runOptions.onTierResolved?.(resolvedTierIdentity.tier);
1063
1223
  if (typeof response === "string") {
1064
1224
  pendingId = response;
1065
1225
  } else if (response.terminal) {
@@ -1087,6 +1247,7 @@ export class WorkflowEngine {
1087
1247
  terminal.error,
1088
1248
  terminal.compactionCount,
1089
1249
  terminal.tokenCount,
1250
+ resolvedTierIdentity?.tier ?? tier,
1090
1251
  );
1091
1252
  result.attemptId = attemptId;
1092
1253
  if (!nested) this.commitCallTerminal(runId, nodeId, result);
@@ -1171,6 +1332,7 @@ export class WorkflowEngine {
1171
1332
  snapshot.error,
1172
1333
  snapshot.compactionCount,
1173
1334
  snapshot.tokenCount,
1335
+ state.callTiers.get(nodeId)?.tier ?? tier,
1174
1336
  );
1175
1337
  result.attemptId = attemptId;
1176
1338
  if (!nested) this.commitCallTerminal(runId, nodeId, result);
@@ -1178,6 +1340,8 @@ export class WorkflowEngine {
1178
1340
  throw wrapError(new Error(snapshot.error), { agentLabel: runOptions.label });
1179
1341
  }
1180
1342
  }
1343
+ const completedTierIdentity = state.callTiers.get(nodeId);
1344
+ if (completedTierIdentity?.tier !== undefined) runOptions.onTierResolved?.(completedTierIdentity.tier);
1181
1345
  return snapshot.result ?? "";
1182
1346
  }
1183
1347
 
@@ -1187,9 +1351,12 @@ export class WorkflowEngine {
1187
1351
  const run = state.run;
1188
1352
  const nodeId = String(entry.index);
1189
1353
  const result = entry.result;
1354
+ const callTierIdentity: CallTierIdentity =
1355
+ entry.tier === undefined ? (run.callTiers[nodeId] ?? {}) : { tier: entry.tier };
1190
1356
  const callResult: CallResult = {
1191
1357
  status: "completed",
1192
1358
  ...(typeof result === "string" && result.length === 0 ? {} : { result }),
1359
+ ...callTierIdentity,
1193
1360
  compactionCount: 0,
1194
1361
  updatedAt: Date.now(),
1195
1362
  };
@@ -1211,6 +1378,7 @@ export class WorkflowEngine {
1211
1378
  };
1212
1379
  this.persist(runId, `call-result:${nodeId}`, () => this.write(event));
1213
1380
  run.callResults[nodeId] = callResult;
1381
+ run.callTiers[nodeId] = callResult.tier === undefined ? {} : { tier: callResult.tier };
1214
1382
  run.callStatus[nodeId] = "completed";
1215
1383
  run.updatedAt = Date.now();
1216
1384
  }
@@ -1222,6 +1390,7 @@ export class WorkflowEngine {
1222
1390
  const result: CallResult = {
1223
1391
  status: "completed",
1224
1392
  ...(entry.result === undefined ? {} : { result: entry.result }),
1393
+ ...(entry.tier === undefined ? {} : { tier: entry.tier }),
1225
1394
  compactionCount: 0,
1226
1395
  updatedAt: Date.now(),
1227
1396
  };
@@ -1245,6 +1414,7 @@ export class WorkflowEngine {
1245
1414
  this.appendRequired(event);
1246
1415
  state.run.workflowResultGenerations[nodeId] = event.generation;
1247
1416
  state.run.callResults[nodeId] = result;
1417
+ state.run.callTiers[nodeId] = result.tier === undefined ? {} : { tier: result.tier };
1248
1418
  state.run.callStatus[nodeId] = "completed";
1249
1419
  state.run.updatedAt = Date.now();
1250
1420
  }
@@ -1266,6 +1436,7 @@ export class WorkflowEngine {
1266
1436
  };
1267
1437
  this.persist(runId, `call-terminal:${nodeId}`, () => this.write(event));
1268
1438
  run.callResults[nodeId] = result;
1439
+ run.callTiers[nodeId] = result.tier === undefined ? {} : { tier: result.tier };
1269
1440
  run.callStatus[nodeId] = result.status;
1270
1441
  if (result.agentId) run.agentIds[nodeId] = result.agentId;
1271
1442
  run.compactions[nodeId] = Math.max(run.compactions[nodeId] ?? 0, result.compactionCount);
@@ -1343,6 +1514,8 @@ export class WorkflowEngine {
1343
1514
  undefined,
1344
1515
  "workflow stopped",
1345
1516
  run.compactions[nodeId] ?? 0,
1517
+ undefined,
1518
+ state.callTiers.get(nodeId)?.tier,
1346
1519
  );
1347
1520
  result.attemptId = run.attemptIds[nodeId];
1348
1521
  terminalResults.push({
@@ -1497,11 +1670,19 @@ export class WorkflowEngine {
1497
1670
  this.providerResumeTimers.delete(runId);
1498
1671
  const state = this.runs.get(runId);
1499
1672
  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
- });
1673
+ void Promise.resolve()
1674
+ .then(() => {
1675
+ const current = this.runs.get(runId);
1676
+ if (current?.run.status !== "paused") return;
1677
+ // resume() is the single authorization gate and captures the fresh
1678
+ // routing-policy fingerprint for this execution.
1679
+ return this.resume(runId, this.readEntries(), { background: true });
1680
+ })
1681
+ .catch((error: unknown) => {
1682
+ console.warn(
1683
+ `[pi-workflows] automatic provider-limit resume failed: ${error instanceof Error ? error.message : String(error)}`,
1684
+ );
1685
+ });
1505
1686
  }, delay);
1506
1687
  timer.unref?.();
1507
1688
  this.providerResumeTimers.set(runId, timer);
@@ -1556,6 +1737,7 @@ export class WorkflowEngine {
1556
1737
  // ── restore / summary ──────────────────────────────────────────────────────
1557
1738
 
1558
1739
  restore(entries: readonly SessionEntryLike[], branchGeneration = 0): void {
1740
+ if (this.disposed) return;
1559
1741
  // A branch replay is a replacement, not a merge. Runs absent from the new
1560
1742
  // branch must not remain controllable or append facts into it.
1561
1743
  this.clearJournalRetries();
@@ -1583,6 +1765,7 @@ export class WorkflowEngine {
1583
1765
  },
1584
1766
  controller: new AbortController(),
1585
1767
  generations: new Map(),
1768
+ callTiers: new Map(Object.entries(run.callTiers ?? {})),
1586
1769
  agentWaiters: new Map(),
1587
1770
  bufferedTerminals: new Map(),
1588
1771
  pendingSpawns: new Map(),
@@ -1658,6 +1841,13 @@ function hashScript(script: string): string {
1658
1841
  return createHash("sha256").update(script).digest("hex");
1659
1842
  }
1660
1843
 
1844
+ export class WorkflowEngineDisposedError extends Error {
1845
+ constructor() {
1846
+ super("Workflow engine is disposed");
1847
+ this.name = "WorkflowEngineDisposedError";
1848
+ }
1849
+ }
1850
+
1661
1851
  export class WorkflowWaitAbortedError extends Error {
1662
1852
  constructor() {
1663
1853
  super("workflow wait aborted");