@signalridge/pi-workflows 1.6.0 → 1.7.1

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
@@ -66,6 +66,9 @@ import type { WorkflowStrengthTable } from "./strengths.js";
66
66
  export const MAX_ATTEMPTS_PER_NODE = 3;
67
67
 
68
68
  const BRANCH_QUIESCE_TIMEOUT_MS = 8_000;
69
+ // Internal deadline, longer than exact-owner RPC cleanup. Scripts can await
70
+ // arbitrary promises, so cancellation cannot rely on cooperative JS unwinding.
71
+ export const EXECUTION_DRAIN_TIMEOUT_MS = 10_000;
69
72
  const MAX_WAITERS = 256;
70
73
  const JOURNAL_RETRY_INITIAL_DELAY_MS = 25;
71
74
  const JOURNAL_RETRY_MAX_DELAY_MS = 2_000;
@@ -116,6 +119,7 @@ export interface ScriptStartOptions {
116
119
  choices?: string[];
117
120
  timeoutMs?: number;
118
121
  },
122
+ signal?: AbortSignal,
119
123
  ) => Promise<unknown>;
120
124
  /** Resolve a nested `workflow(name)` reference to the script it names. */
121
125
  loadSavedWorkflow?: (name: string) => string | undefined;
@@ -136,12 +140,32 @@ interface AgentLifecycleWaiter {
136
140
  generation: number;
137
141
  onUsage?: (usage: AgentUsage) => void;
138
142
  onHistory?: (history: unknown[]) => void;
143
+ /** Told once when the host reports this agent left the queue and started. */
144
+ onRunning?: () => void;
145
+ startedNotified: boolean;
139
146
  executionGeneration: number;
140
147
  resolve: (response: ManagedSpawnResponse) => void;
141
148
  reject: (error: unknown) => void;
142
149
  settled: boolean;
143
150
  }
144
151
 
152
+ /**
153
+ * Tell a waiter once that its agent started.
154
+ *
155
+ * Once, because the wall clock is restarted from this point: a repeated report
156
+ * — a duplicate event, or a reply that raced its own lifecycle event — would
157
+ * keep pushing the deadline out and quietly turn the timeout off.
158
+ */
159
+ function notifyAgentRunning(waiter: AgentLifecycleWaiter): void {
160
+ if (waiter.startedNotified || waiter.settled) return;
161
+ waiter.startedNotified = true;
162
+ try {
163
+ waiter.onRunning?.();
164
+ } catch {
165
+ /* the clock is best effort; never fail a live agent over it */
166
+ }
167
+ }
168
+
145
169
  interface AgentTerminalDelivery {
146
170
  response: ManagedSpawnResponse;
147
171
  usage?: AgentUsage;
@@ -432,6 +456,10 @@ export class WorkflowEngine {
432
456
  if (updateCallTierIdentity(state, callNodeId, data.tier)) {
433
457
  this.persistCallTierIdentity(state, callNodeId);
434
458
  }
459
+ // A start is the moment the host admitted this agent from its queue. It is
460
+ // the only signal that separates waiting for a slot from doing the work, so
461
+ // the call's wall clock is restarted here rather than at dispatch.
462
+ if (eventName === "subagents:started" && waiterMatches && waiter) notifyAgentRunning(waiter);
435
463
  // Created/started lifecycle facts carry managed identity before the RPC
436
464
  // reply is necessarily available. They are observation-only here; terminal
437
465
  // ownership is handled below once a completion/failure fact arrives.
@@ -730,6 +758,7 @@ export class WorkflowEngine {
730
758
  this.setWorkflowStatus(run, "running");
731
759
  state.executionGeneration += 1;
732
760
  state.execution = this.execute(runId, script, startOptions, undefined, state.executionGeneration, protocol);
761
+ const execution = state.execution;
733
762
  // A run-fatal abort/stop already journals its terminal state; never let a
734
763
  // leftover rejection become an unhandled promise rejection.
735
764
  void state.execution.catch(() => {});
@@ -747,6 +776,10 @@ export class WorkflowEngine {
747
776
  };
748
777
  } catch (error: unknown) {
749
778
  if (!(error instanceof WorkflowWaitAbortedError)) throw error;
779
+ // Foreground cancellation owns execution, not just this waiter. Do not
780
+ // advertise resumability until runtime children and checkpoint UI unwind.
781
+ state.controller.abort();
782
+ await this.drainExecution(state, execution);
750
783
  const current = this.runs.get(runId)?.run ?? run;
751
784
  return { runId: current.runId, status: current.status, background: false, waitAborted: true };
752
785
  }
@@ -771,6 +804,7 @@ export class WorkflowEngine {
771
804
  if (!state) return undefined;
772
805
  if (state.lifecycleSuspended) return undefined;
773
806
  const run = state.run;
807
+ if (run.status === "running" || run.status === "pausing" || run.status === "stopping") return undefined;
774
808
  const providerTimer = this.providerResumeTimers.get(runId);
775
809
  if (providerTimer) {
776
810
  clearTimeout(providerTimer);
@@ -838,13 +872,16 @@ export class WorkflowEngine {
838
872
  state.executionGeneration,
839
873
  protocol,
840
874
  );
841
- void state.execution.catch(() => {});
875
+ const execution = state.execution;
876
+ void execution.catch(() => {});
842
877
  if (options.background) return { runId, status: run.status, background: true };
843
878
  try {
844
879
  const settled = await this.waitFor(runId, options.signal);
845
880
  return { runId, status: settled.status, background: false };
846
881
  } catch (error: unknown) {
847
882
  if (!(error instanceof WorkflowWaitAbortedError)) throw error;
883
+ state.controller.abort();
884
+ await this.drainExecution(state, execution);
848
885
  return { runId, status: run.status, background: false, waitAborted: true };
849
886
  }
850
887
  }
@@ -872,21 +909,7 @@ export class WorkflowEngine {
872
909
  // resumed from the journal. The script's in-flight agents are stopped by
873
910
  // the runtime's abort signal via the dispatch waiters.
874
911
  state.controller.abort();
875
- this.persist(runId, "pause-recovery", () =>
876
- this.write({
877
- kind: "run_recovery",
878
- schemaVersion: JOURNAL_SCHEMA_VERSION,
879
- runId,
880
- status: "interrupted",
881
- branchGeneration: this.recoveryBranchGeneration,
882
- rotations: [],
883
- recoveryId: deriveRecoveryId({ runId, rotations: [], timestamp: Date.now() }),
884
- timestamp: Date.now(),
885
- }),
886
- );
887
- run.status = "interrupted";
888
- run.updatedAt = Date.now();
889
- this.settleWaiters(runId, run);
912
+ await this.drainExecution(state, state.execution);
890
913
  } else if (run.status !== "paused" && run.status !== "interrupted") {
891
914
  throw new Error(`cannot pause workflow in state ${run.status}`);
892
915
  }
@@ -993,11 +1016,34 @@ export class WorkflowEngine {
993
1016
  const state = this.runs.get(runId);
994
1017
  if (!state) throw new Error(`workflow run not found: ${runId}`);
995
1018
  const generation = executionGeneration ?? state.executionGeneration;
1019
+ const executionSignal =
1020
+ !options.background && options.signal
1021
+ ? AbortSignal.any([state.controller.signal, options.signal])
1022
+ : state.controller.signal;
996
1023
 
1024
+ const cleanupTasks = new Set<Promise<void>>();
997
1025
  const runner: WorkflowAgentRunner = {
998
1026
  run: async (prompt: string, runOptions: AgentRunOptions = {}) => {
999
- return this.dispatchAgent(runId, prompt, runOptions);
1027
+ if (state.executionGeneration !== generation)
1028
+ throw new WorkflowError("stale workflow execution", WorkflowErrorCode.WORKFLOW_ABORTED);
1029
+ const dispatch = this.dispatchAgent(runId, prompt, runOptions);
1030
+ const settled = dispatch.then(
1031
+ () => {},
1032
+ () => {},
1033
+ );
1034
+ const trackCleanup = () => {
1035
+ cleanupTasks.add(settled);
1036
+ void settled.then(() => cleanupTasks.delete(settled));
1037
+ };
1038
+ runOptions.signal?.addEventListener("abort", trackCleanup, { once: true });
1039
+ if (runOptions.signal?.aborted) trackCleanup();
1040
+ try {
1041
+ return await dispatch;
1042
+ } finally {
1043
+ runOptions.signal?.removeEventListener("abort", trackCleanup);
1044
+ }
1000
1045
  },
1046
+ waitForCleanup: () => this.drainExecution(state, Promise.all([...cleanupTasks])),
1001
1047
  };
1002
1048
 
1003
1049
  try {
@@ -1010,12 +1056,16 @@ export class WorkflowEngine {
1010
1056
  : {
1011
1057
  routingPolicy: protocol.routingPolicy,
1012
1058
  routingPolicyFingerprint: protocol.routingPolicyFingerprint,
1059
+ // Read live at each start and resume, never frozen onto the run:
1060
+ // the pool is a host setting the user can change between them, and
1061
+ // a run that outlived a narrowing would dispatch into a queue.
1062
+ ...(protocol.maxConcurrent === undefined ? {} : { hostMaxConcurrent: protocol.maxConcurrent }),
1013
1063
  }),
1014
1064
  concurrency: options.concurrency,
1015
1065
  agentRetries: options.agentRetries,
1016
1066
  tokenBudget: options.tokenBudget,
1017
1067
  agentTimeoutMs: options.agentTimeoutMs,
1018
- signal: state.controller.signal,
1068
+ signal: executionSignal,
1019
1069
  maxAgents: options.maxAgents,
1020
1070
  runId,
1021
1071
  executionNonce: `${generation}-${randomUUID()}`,
@@ -1050,6 +1100,12 @@ export class WorkflowEngine {
1050
1100
  }
1051
1101
  },
1052
1102
  });
1103
+ await runner.waitForCleanup?.();
1104
+ if (executionSignal.aborted) {
1105
+ throw new WorkflowError("workflow execution aborted", WorkflowErrorCode.WORKFLOW_ABORTED, {
1106
+ recoverable: true,
1107
+ });
1108
+ }
1053
1109
  const live = this.runs.get(runId);
1054
1110
  const run = live?.run;
1055
1111
  if (run && state.executionGeneration === generation && !isTerminalWorkflow(run.status)) {
@@ -1058,6 +1114,7 @@ export class WorkflowEngine {
1058
1114
  }
1059
1115
  return result;
1060
1116
  } catch (error: unknown) {
1117
+ await runner.waitForCleanup?.();
1061
1118
  const run = this.runs.get(runId)?.run;
1062
1119
  if (run && state.executionGeneration === generation && !isTerminalWorkflow(run.status)) {
1063
1120
  const message = error instanceof Error ? error.message : String(error);
@@ -1066,8 +1123,21 @@ export class WorkflowEngine {
1066
1123
  this.setWorkflowStatus(run, "paused", message);
1067
1124
  this.cleanupAgents(runId);
1068
1125
  this.scheduleProviderResume(runId, workflowError.resetHint);
1069
- } else if (workflowError.code === WorkflowErrorCode.WORKFLOW_ABORTED && state.controller.signal.aborted) {
1070
- this.setWorkflowStatus(run, "interrupted");
1126
+ } else if (workflowError.code === WorkflowErrorCode.WORKFLOW_ABORTED && executionSignal.aborted) {
1127
+ this.persist(runId, "abort-recovery", () =>
1128
+ this.write({
1129
+ kind: "run_recovery",
1130
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1131
+ runId,
1132
+ status: "interrupted",
1133
+ branchGeneration: this.recoveryBranchGeneration,
1134
+ rotations: [],
1135
+ recoveryId: deriveRecoveryId({ runId, rotations: [], timestamp: Date.now() }),
1136
+ timestamp: Date.now(),
1137
+ }),
1138
+ );
1139
+ run.status = "interrupted";
1140
+ run.updatedAt = Date.now();
1071
1141
  } else {
1072
1142
  this.setWorkflowStatus(run, "failed", message);
1073
1143
  this.cleanupAgents(runId);
@@ -1091,7 +1161,7 @@ export class WorkflowEngine {
1091
1161
  if (this.disposed) throw new WorkflowEngineDisposedError();
1092
1162
  const state = this.runs.get(runId);
1093
1163
  if (!state) throw new Error(`workflow run not found: ${runId}`);
1094
- if (state.lifecycleSuspended || state.controller.signal.aborted) {
1164
+ if (state.lifecycleSuspended || state.controller.signal.aborted || runOptions.signal?.aborted) {
1095
1165
  throw new WorkflowError("workflow branch is quiescing", WorkflowErrorCode.WORKFLOW_ABORTED, {
1096
1166
  recoverable: true,
1097
1167
  });
@@ -1192,6 +1262,19 @@ export class WorkflowEngine {
1192
1262
  let response: ManagedSpawnResponse | string;
1193
1263
  try {
1194
1264
  response = await this.client.spawn(task, runId, spawnNodeId, spawnAttemptId, externalSignal);
1265
+ } catch (error: unknown) {
1266
+ if (externalSignal.aborted) {
1267
+ const reconciled = await this.client.reconcileManaged?.(spawnKey, owner).catch(() => undefined);
1268
+ const id = typeof reconciled === "string" ? reconciled : reconciled?.id;
1269
+ await this.stopAndQuiesce(
1270
+ state,
1271
+ id ?? spawnKey,
1272
+ owner,
1273
+ executionGeneration,
1274
+ id ? undefined : "cancelled spawn allocation could not be confirmed",
1275
+ );
1276
+ }
1277
+ throw error;
1195
1278
  } finally {
1196
1279
  externalSignal.removeEventListener("abort", onPendingAbort);
1197
1280
  state.pendingSpawns.delete(spawnKey);
@@ -1203,9 +1286,7 @@ export class WorkflowEngine {
1203
1286
  externalSignal.aborted
1204
1287
  ) {
1205
1288
  state.bufferedTerminals.delete(spawnKey);
1206
- await this.client
1207
- .stopOwned?.(responseId, { extension: "pi-workflows", runId, nodeId: spawnNodeId, attemptId: spawnAttemptId })
1208
- .catch(() => {});
1289
+ await this.stopAndQuiesce(state, responseId, owner, executionGeneration);
1209
1290
  throw new WorkflowError(
1210
1291
  "workflow dispatch became stale during pause/resume",
1211
1292
  WorkflowErrorCode.WORKFLOW_ABORTED,
@@ -1266,15 +1347,12 @@ export class WorkflowEngine {
1266
1347
  waiter.settled = true;
1267
1348
  state.agentWaiters.delete(pendingId);
1268
1349
  state.bufferedTerminals.delete(spawnKey);
1269
- void this.client
1270
- .stopOwned?.(pendingId, {
1271
- extension: "pi-workflows",
1272
- runId,
1273
- nodeId: spawnNodeId,
1274
- attemptId: spawnAttemptId,
1275
- })
1276
- .catch(() => {});
1277
- waiter.reject(new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true }));
1350
+ // Stop acknowledges cancellation, not provider/tool finally completion.
1351
+ void this.stopAndQuiesce(state, pendingId, owner, executionGeneration).then(() => {
1352
+ waiter.reject(
1353
+ new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true }),
1354
+ );
1355
+ });
1278
1356
  }
1279
1357
  };
1280
1358
 
@@ -1287,11 +1365,17 @@ export class WorkflowEngine {
1287
1365
  executionGeneration,
1288
1366
  onUsage: runOptions.onUsage,
1289
1367
  onHistory: runOptions.onHistory,
1368
+ onRunning: runOptions.onRunning,
1369
+ startedNotified: false,
1290
1370
  settled: false,
1291
1371
  resolve,
1292
1372
  reject,
1293
1373
  };
1294
1374
  state.agentWaiters.set(pendingId, waiter);
1375
+ // A spawn that was admitted straight away is already running, and its
1376
+ // `subagents:started` event fired before this waiter existed. Only a
1377
+ // reply that says "queued" still has a start to wait for.
1378
+ if (typeof response !== "string" && response.state !== "queued") notifyAgentRunning(waiter);
1295
1379
  externalSignal.addEventListener("abort", waitAbort, { once: true });
1296
1380
  if (externalSignal.aborted) {
1297
1381
  waitAbort();
@@ -1498,6 +1582,99 @@ export class WorkflowEngine {
1498
1582
  }
1499
1583
  }
1500
1584
 
1585
+ private async stopAndQuiesce(
1586
+ state: ScriptRunState,
1587
+ agentId: string,
1588
+ owner: WorkflowOwner,
1589
+ executionGeneration: number,
1590
+ unconfirmed?: string,
1591
+ ): Promise<void> {
1592
+ let timer: ReturnType<typeof setTimeout> | undefined;
1593
+ let stopError: unknown;
1594
+ try {
1595
+ if (unconfirmed) throw new Error(unconfirmed);
1596
+ await Promise.race([
1597
+ (async () => {
1598
+ try {
1599
+ if (!this.client.stopOwned) throw new Error("owned stop RPC unavailable");
1600
+ await this.client.stopOwned(agentId, owner);
1601
+ } catch (error: unknown) {
1602
+ // Reconciliation may already have stopped the record. Only exact-owner
1603
+ // quiescence confirms that provider/tool cleanup has actually settled.
1604
+ stopError = error;
1605
+ }
1606
+ if (!this.client.quiesceOwned) throw new Error("owned cleanup RPC unavailable");
1607
+ const result = await this.client.quiesceOwned(owner.runId, [agentId], BRANCH_QUIESCE_TIMEOUT_MS, [owner]);
1608
+ if (!result?.settled || result.pending.length !== 0) throw new Error("owned agents are not quiescent");
1609
+ })(),
1610
+ new Promise<never>((_resolve, reject) => {
1611
+ timer = setTimeout(() => reject(new Error("owned cleanup timed out")), BRANCH_QUIESCE_TIMEOUT_MS + 1_000);
1612
+ }),
1613
+ ]);
1614
+ } catch (error: unknown) {
1615
+ // Never turn uncertain cleanup into a resumable interruption. Existing
1616
+ // terminal recovery facts durably fence this run without a protocol change.
1617
+ if (this.runs.get(owner.runId) !== state || state.executionGeneration !== executionGeneration) return;
1618
+ const stopDiagnostic = stopError === undefined ? "" : `; stop failed: ${String(stopError)}`;
1619
+ const diagnostic = `Owned cleanup unconfirmed for ${agentId}: ${error instanceof Error ? error.message : String(error)}${stopDiagnostic}; run is non-resumable`;
1620
+ this.fenceExecution(state, executionGeneration, diagnostic);
1621
+ } finally {
1622
+ if (timer !== undefined) clearTimeout(timer);
1623
+ }
1624
+ }
1625
+
1626
+ private async drainExecution(state: ScriptRunState, execution?: Promise<unknown>): Promise<void> {
1627
+ if (!execution) return;
1628
+ const generation = state.executionGeneration;
1629
+ let timer: ReturnType<typeof setTimeout> | undefined;
1630
+ try {
1631
+ await Promise.race([
1632
+ execution.catch(() => {}),
1633
+ new Promise<void>((resolve) => {
1634
+ timer = setTimeout(() => {
1635
+ this.fenceExecution(state, generation, "Workflow execution cleanup timed out; run is non-resumable");
1636
+ resolve();
1637
+ }, EXECUTION_DRAIN_TIMEOUT_MS);
1638
+ }),
1639
+ ]);
1640
+ } finally {
1641
+ if (timer !== undefined) clearTimeout(timer);
1642
+ }
1643
+ }
1644
+
1645
+ private fenceExecution(state: ScriptRunState, generation: number, diagnostic: string): void {
1646
+ const run = state.run;
1647
+ if (this.runs.get(run.runId) !== state || state.executionGeneration !== generation) return;
1648
+ if (run.nonResumable && run.status === "stopped") return;
1649
+ run.nonResumable = true;
1650
+ const blockedNodeIds = Object.keys(run.callStatus).filter((nodeId) => run.callStatus[nodeId] !== "completed");
1651
+ for (const nodeId of blockedNodeIds) run.callStatus[nodeId] = "stopped";
1652
+ run.status = "stopped";
1653
+ run.error = diagnostic;
1654
+ run.terminalIntent = "stop";
1655
+ run.updatedAt = Date.now();
1656
+ const event: TerminalRecoveryEvent = {
1657
+ kind: "terminal_recovery",
1658
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1659
+ runId: run.runId,
1660
+ status: "stopped",
1661
+ terminalIntent: "stop",
1662
+ branchGeneration: this.recoveryBranchGeneration,
1663
+ terminalResults: [],
1664
+ blockedNodeIds,
1665
+ error: diagnostic,
1666
+ recoveryId: deriveRecoveryId({ runId: run.runId, rotations: [], timestamp: Date.now() }),
1667
+ timestamp: Date.now(),
1668
+ };
1669
+ this.persist(run.runId, "cleanup-unconfirmed", () => this.write(event));
1670
+ // Fence before aborting: synchronous abort handlers may re-enter cleanup.
1671
+ // Exact-owner cleanup continues, but no later result may revive this run.
1672
+ state.executionGeneration += 1;
1673
+ state.controller.abort();
1674
+ this.cleanupAgents(run.runId);
1675
+ this.settleWaiters(run.runId, run);
1676
+ }
1677
+
1501
1678
  private async stop(state: ScriptRunState): Promise<void> {
1502
1679
  const run = state.run;
1503
1680
  if (isTerminalWorkflow(run.status)) return;
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { defineTool } from "@earendil-works/pi-coding-agent";
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import { Type } from "@sinclair/typebox";
@@ -36,11 +36,40 @@ const WorkflowToolSchema = Type.Object(
36
36
  name: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
37
37
  args: Type.Optional(Type.Any()),
38
38
  background: Type.Optional(Type.Boolean()),
39
- maxAgents: Type.Optional(Type.Number({ minimum: 1 })),
40
- concurrency: Type.Optional(Type.Number({ minimum: 1 })),
41
- agentRetries: Type.Optional(Type.Number({ minimum: 0, maximum: 3 })),
42
- tokenBudget: Type.Optional(Type.Number({ minimum: 1 })),
43
- agentTimeoutMs: Type.Optional(Type.Number({ minimum: 1 })),
39
+ maxAgents: Type.Optional(
40
+ Type.Number({
41
+ minimum: 1,
42
+ description:
43
+ "Cap on real agent dispatches for this run. Default 1000, which no ordinary script reaches. Omit it: a value below what the script dispatches stops the run partway with AGENT_LIMIT_EXCEEDED.",
44
+ }),
45
+ ),
46
+ concurrency: Type.Optional(
47
+ Type.Number({
48
+ minimum: 1,
49
+ description:
50
+ "Agents in flight at once. Defaults to the host's own subagent pool size, which is also the ceiling. Omit it: a lower number runs the same work more slowly and buys nothing, and a higher one is clamped.",
51
+ }),
52
+ ),
53
+ agentRetries: Type.Optional(
54
+ Type.Number({
55
+ minimum: 0,
56
+ maximum: 3,
57
+ description: "Retries for a recoverable agent failure. Default 0; a failed call returns null to the script.",
58
+ }),
59
+ ),
60
+ tokenBudget: Type.Optional(
61
+ Type.Number({
62
+ minimum: 1,
63
+ description: "Soft token ceiling for the whole run. Default unlimited; exhausting a set budget aborts the run.",
64
+ }),
65
+ ),
66
+ agentTimeoutMs: Type.Optional(
67
+ Type.Number({
68
+ minimum: 1,
69
+ description:
70
+ "Wall clock per agent, there to bound a hang. Default 1800000 (30min), and it counts from when the agent actually starts, not from dispatch. An agent's real budget is the host's turn, tool and token ceiling. Omit it: a short value fails an entire fan-out as timeouts.",
71
+ }),
72
+ ),
44
73
  resumeFromRunId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
45
74
  },
46
75
  { additionalProperties: false },
@@ -128,8 +157,14 @@ function formatStart(result: ScriptStartResult): string {
128
157
  `Workflow ${result.background ? "started in background" : result.status}: ${result.runId}`,
129
158
  `status=${result.status}`,
130
159
  ];
131
- if (result.waitAborted)
132
- lines.push("wait_aborted=true", "The workflow run continues; retrieve it with workflow_control.");
160
+ if (result.waitAborted) {
161
+ lines.push(
162
+ "wait_aborted=true",
163
+ result.background
164
+ ? "Only the wait was cancelled; retrieve the detached run with workflow_control."
165
+ : "Foreground execution was cancelled; inspect its status with workflow_control before resuming.",
166
+ );
167
+ }
133
168
  if (result.result) lines.push("", result.result.slice(0, 8_000));
134
169
  if (result.error) lines.push("", `error=${result.error.slice(0, 2_000)}`);
135
170
  return lines.join("\n");
@@ -139,7 +174,7 @@ function formatStart(result: ScriptStartResult): string {
139
174
  export function buildAdHocWorkflowScript(prompt: string): string {
140
175
  return `export const meta = { name: "ad-hoc", description: "Plan, execute, and synthesize a bounded multi-agent task graph" };
141
176
  const originalPrompt = ${JSON.stringify(prompt)};
142
- const plan = await agent("Design a bounded execution plan for this user task. Return 1-8 independent or dependency-linked tasks. Use unique short ids, concise descriptions, exact worker prompts, and only dependencies that appear in the task list. Prefer independent tasks when possible. User task:\\n\\n" + originalPrompt, {
177
+ const plan = await agent("Design a bounded execution plan for this user task. Return 1-24 independent or dependency-linked tasks. Use unique short ids, concise descriptions, exact worker prompts, and only dependencies that appear in the task list. Prefer independent tasks when possible, and prefer narrow tasks over few broad ones: a task asked one question is read further into than a task asked four. User task:\\n\\n" + originalPrompt, {
143
178
  label: "planner",
144
179
  strength: "medium",
145
180
  schema: {
@@ -148,14 +183,14 @@ const plan = await agent("Design a bounded execution plan for this user task. Re
148
183
  tasks: {
149
184
  type: "array",
150
185
  minItems: 1,
151
- maxItems: 8,
186
+ maxItems: 24,
152
187
  items: {
153
188
  type: "object",
154
189
  properties: {
155
190
  id: { type: "string", minLength: 1, maxLength: 64 },
156
191
  description: { type: "string", minLength: 1, maxLength: 500 },
157
192
  prompt: { type: "string", minLength: 1, maxLength: 8000 },
158
- dependsOn: { type: "array", maxItems: 8, items: { type: "string", minLength: 1, maxLength: 64 } },
193
+ dependsOn: { type: "array", maxItems: 24, items: { type: "string", minLength: 1, maxLength: 64 } },
159
194
  },
160
195
  required: ["id", "description", "prompt"],
161
196
  additionalProperties: false,
@@ -308,6 +343,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
308
343
  const MAX_PENDING_DELIVERIES = 256;
309
344
  const PENDING_DELIVERY_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
310
345
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
346
+ let widgetUi: ExtensionContext["ui"] | undefined;
311
347
 
312
348
  const currentEngine = (): WorkflowEngine => {
313
349
  if (!active || !engine || engine.isDisposed())
@@ -316,12 +352,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
316
352
  };
317
353
 
318
354
  const clearWidget = (): void => {
319
- const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
320
- tui?.setWidget?.("pi-workflows", undefined);
355
+ widgetUi?.setWidget("pi-workflows", undefined);
321
356
  };
322
357
  const refreshWidget = (): void => {
323
- const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
324
- if (!tui?.setWidget) return;
358
+ const tui = widgetUi;
359
+ if (!tui) return;
325
360
  const workflowEngine = engine;
326
361
  if (!active || !workflowEngine || workflowEngine.isDisposed()) {
327
362
  tui.setWidget("pi-workflows", undefined);
@@ -346,15 +381,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
346
381
  };
347
382
  const notifyRunChanged = (): void => refreshWidget();
348
383
 
349
- const branchEntries = (): SessionEntryLike[] => {
350
- const ctx = (pi as unknown as { currentSessionManager?: { getBranch?: () => unknown } }).currentSessionManager;
351
- try {
352
- return (ctx?.getBranch?.() as SessionEntryLike[] | undefined) ?? [];
353
- } catch {
354
- return [];
355
- }
356
- };
357
-
358
384
  const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
359
385
 
360
386
  /**
@@ -406,6 +432,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
406
432
  active = true;
407
433
  const workflowEngine = currentEngine();
408
434
  workflowEngine.onRunSettled = notifyRunChanged;
435
+ workflowEngine.onRuntimeEvent = notifyRunChanged;
409
436
  // `control("resume")` and the provider-limit retry start executions with no
410
437
  // caller to pass options for them, and the strength table is deliberately
411
438
  // never frozen onto a run. Without this they would resume with no table and
@@ -427,9 +454,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
427
454
  promptGuidelines: [
428
455
  "Provide either a full `script` (raw JavaScript with the meta contract first) or a `name` of a saved/built-in workflow.",
429
456
  "Use orchestrate([{ id, dependsOn, run }]) for named dependency graphs; use parallel(() => agent(...), ...) for a simple fan-out and pipeline(items, ...stages) for sequential per-item stages.",
457
+ "Size the fan-out to the work: one agent per independent piece of evidence the task actually has. Ten to thirty agents is an ordinary review or audit, and a script that splits a broad task four ways is usually four agents each doing one agent's job. The gain is coverage, not speed — how many run at once is the host's pool, not the script's choice.",
458
+ "Pass script/name/args/background and nothing else unless the user named a specific limit. maxAgents, concurrency, agentRetries, tokenBudget and agentTimeoutMs already default to tuned values, and each one stops or fails the run when it is set too low.",
430
459
  "Use background: true for long runs; retrieve results with workflow_control.",
431
460
  "Use resumeFromRunId with an edited script to replay the unchanged prefix from cache and re-run the rest live.",
432
- "Do not include model, thinking, concurrency, retry, timeout, or turn-limit settings beyond the declared options.",
461
+ "Inside the script, never name a model, a thinking level, or a turn limit: `strength` is the only routing a call may express.",
433
462
  ],
434
463
  parameters: WorkflowToolSchema,
435
464
  renderCall(args, theme) {
@@ -478,13 +507,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
478
507
  // Foreground checkpoint confirmation only when the run is not
479
508
  // background — background runs are headless by contract.
480
509
  confirm:
481
- params.background === false && ctx?.hasUI && ctx.mode === "tui"
482
- ? async (promptText, checkpointOptions) => {
510
+ params.background === false && ctx?.hasUI
511
+ ? async (promptText, checkpointOptions, signal) => {
483
512
  if (checkpointOptions.kind === "input")
484
- return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""));
513
+ return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""), { signal });
485
514
  if (checkpointOptions.kind === "select")
486
- return ctx.ui.select(promptText, checkpointOptions.choices ?? []);
487
- return ctx.ui.confirm(promptText, promptText);
515
+ return ctx.ui.select(promptText, checkpointOptions.choices ?? [], { signal });
516
+ return ctx.ui.confirm(promptText, promptText, { signal });
488
517
  }
489
518
  : undefined,
490
519
  loadSavedWorkflow: resolveNestedWorkflow,
@@ -494,7 +523,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
494
523
  const replacement = params.script !== undefined || params.name !== undefined ? script : undefined;
495
524
  const resumed = await workflowEngine.resume(
496
525
  params.resumeFromRunId,
497
- branchEntries(),
526
+ ctx.sessionManager.getBranch(),
498
527
  options,
499
528
  replacement,
500
529
  );
@@ -929,7 +958,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
929
958
  if (event.text.includes(WORKFLOW_ARMED_DIRECTIVE)) return { action: "continue" as const };
930
959
  const effortDirective =
931
960
  effortLevel === "ultra"
932
- ? 'Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks; give the deepest steps strength: "high".'
961
+ ? 'Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks. Width is where the exhaustiveness goes: keep steps at strength: "medium" and reserve "high" for a step whose error is expensive to reverse, if the run has one at all.'
933
962
  : effortLevel === "high"
934
963
  ? "Effort: HIGH. Be thorough: use several independent reviewers and an adversarial verification pass."
935
964
  : "";
@@ -1085,6 +1114,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1085
1114
  } finally {
1086
1115
  workflowEngine.resumeLifecycle();
1087
1116
  branchQuiesce = undefined;
1117
+ refreshWidget();
1088
1118
  }
1089
1119
  });
1090
1120
  };
@@ -1226,6 +1256,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1226
1256
  {
1227
1257
  append(event) {
1228
1258
  pi.appendEntry(JOURNAL_ENTRY_TYPE, event);
1259
+ // Journal writes precede some state mutations; refresh once applied.
1260
+ queueMicrotask(() => {
1261
+ if (generation === lifecycleGeneration) refreshWidget();
1262
+ });
1229
1263
  },
1230
1264
  },
1231
1265
  () => {
@@ -1257,7 +1291,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1257
1291
  return;
1258
1292
  }
1259
1293
 
1294
+ if (generation !== lifecycleGeneration) {
1295
+ recoveredEngine.dispose();
1296
+ return;
1297
+ }
1260
1298
  engine = recoveredEngine;
1299
+ widgetUi = ctx.mode === "tui" ? ctx.ui : undefined;
1261
1300
  registerSurface();
1262
1301
 
1263
1302
  // Bind per-session delivery endpoint (A10): capture the session-bound send
@@ -1351,6 +1390,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1351
1390
  // engine quiescence; the old session must not accept another run.
1352
1391
  active = false;
1353
1392
  clearWidget();
1393
+ widgetUi = undefined;
1354
1394
  if (widgetTimer) {
1355
1395
  clearInterval(widgetTimer);
1356
1396
  widgetTimer = undefined;