@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/rpc-client.ts CHANGED
@@ -172,6 +172,16 @@ export interface ManagedProtocolCheck {
172
172
  routingPolicy: ManagedRoutingPolicy;
173
173
  /** Whole-catalogue identity; used only where a per-tier identity cannot be. */
174
174
  routingPolicyFingerprint: string;
175
+ /**
176
+ * The host's background-agent pool size, when the peer published one.
177
+ *
178
+ * A workflow agent occupies one of those slots like any other background
179
+ * agent, so this is the width the run can actually achieve; dispatching past
180
+ * it only fills the host's queue. Undefined means the peer predates the
181
+ * field or answered through the readiness broadcast, which carries no
182
+ * requested fields — the caller then falls back to its own default.
183
+ */
184
+ maxConcurrent?: number;
175
185
  }
176
186
 
177
187
  export async function checkManagedSpawnProtocol(
@@ -213,7 +223,9 @@ export async function checkManagedSpawnProtocol(
213
223
  });
214
224
  const sendPing = () => {
215
225
  try {
216
- events.emit("subagents:rpc:ping", { requestId: id });
226
+ // Additive reply fields are requested by name: the reply envelope is
227
+ // strictly parsed, so a peer may not volunteer one.
228
+ events.emit("subagents:rpc:ping", { requestId: id, include: ["maxConcurrent"] });
217
229
  } catch (error: unknown) {
218
230
  finish(undefined, error instanceof Error ? error : new Error(String(error)));
219
231
  }
@@ -254,6 +266,7 @@ export async function checkManagedSpawnProtocol(
254
266
  return {
255
267
  routingPolicy: ping.routingPolicy.policy,
256
268
  routingPolicyFingerprint: ping.routingPolicy.fingerprint,
269
+ ...(ping.maxConcurrent === undefined ? {} : { maxConcurrent: ping.maxConcurrent }),
257
270
  };
258
271
  }
259
272
 
@@ -367,7 +380,7 @@ export function createManagedSpawnClient(
367
380
  agentId,
368
381
  owner,
369
382
  },
370
- sessionSignal,
383
+ undefined,
371
384
  );
372
385
  },
373
386
  async reconcileManaged(spawnKey, owner) {
@@ -390,10 +403,15 @@ export function createManagedSpawnClient(
390
403
  ...(owners ? { owners } : {}),
391
404
  timeoutMs,
392
405
  },
393
- sessionSignal,
406
+ undefined,
394
407
  timeoutMs + 1_000,
395
408
  );
396
- if (!isRecord(data) || typeof data.settled !== "boolean" || !Array.isArray(data.pending)) {
409
+ if (
410
+ !isRecord(data) ||
411
+ typeof data.settled !== "boolean" ||
412
+ !Array.isArray(data.pending) ||
413
+ !data.pending.every((id) => typeof id === "string" && id.length > 0)
414
+ ) {
397
415
  throw new Error("subagents quiescence returned an invalid response");
398
416
  }
399
417
  const pending = data.pending.filter((id): id is string => typeof id === "string");
package/src/runtime.ts CHANGED
@@ -116,6 +116,8 @@ export type Limiter = <T>(fn: () => Promise<T>) => Promise<T>;
116
116
  /** The dispatch boundary the engine implements over spawn-managed RPC. */
117
117
  export interface WorkflowAgentRunner {
118
118
  run(prompt: string, options?: AgentRunOptions): Promise<unknown>;
119
+ /** Managed runners bound and confirm cleanup of aborted dispatches. */
120
+ waitForCleanup?(): Promise<void>;
119
121
  }
120
122
 
121
123
  export interface AgentRunOptions {
@@ -155,6 +157,16 @@ export interface AgentRunOptions {
155
157
  onTierResolved?: (tier: string) => void;
156
158
  onUsage?: (usage: AgentUsage) => void;
157
159
  onHistory?: (history: unknown[]) => void;
160
+ /**
161
+ * The agent left the host's queue and is now running.
162
+ *
163
+ * A dispatched agent does not necessarily start: it takes a background slot
164
+ * from pi-subagents, and a fan-out wider than that pool waits. The wall clock
165
+ * is restarted from this point so queue time is not charged against the work,
166
+ * which is what let a batch dispatched behind a busy pool fail as timeouts
167
+ * without any of it having run.
168
+ */
169
+ onRunning?: () => void;
158
170
  }
159
171
 
160
172
  export interface AgentOptions {
@@ -260,6 +272,17 @@ export interface WorkflowRunOptions {
260
272
  workflowDepth?: number;
261
273
  cwd?: string;
262
274
  concurrency?: number;
275
+ /**
276
+ * The host's background-agent pool size, read live from the peer at each
277
+ * start and resume.
278
+ *
279
+ * It is both the default width and the ceiling: a workflow agent occupies one
280
+ * of those slots like any other background agent, so dispatching past the pool
281
+ * does not run wider — it fills the host's single queue ahead of the host's own
282
+ * spawns. One setting therefore governs the fleet, and a run neither has to
283
+ * guess it nor can exceed it.
284
+ */
285
+ hostMaxConcurrent?: number;
263
286
  agentRetries?: number;
264
287
  tokenBudget?: number | null;
265
288
  signal?: AbortSignal;
@@ -279,7 +302,7 @@ export interface WorkflowRunOptions {
279
302
  sharedStore?: SharedStore;
280
303
  /** Resolve a nested `workflow(name)` reference to the script it names. */
281
304
  loadSavedWorkflow?: (name: string) => string | undefined;
282
- confirm?: (promptText: string, options: CheckpointOptions) => Promise<unknown>;
305
+ confirm?: (promptText: string, options: CheckpointOptions, signal?: AbortSignal) => Promise<unknown>;
283
306
  onLog?: (message: string) => void;
284
307
  onPhase?: (title: string) => void;
285
308
  onRuntimeEvent?: (event: WorkflowRuntimeEvent) => void;
@@ -337,9 +360,36 @@ export const MAX_AGENTS_PER_RUN = 1000;
337
360
  export const MAX_CALLS_PER_WORKFLOW = 1_000;
338
361
  /** Per-call item cap, matching the bounded batch contract used by reference engines. */
339
362
  export const MAX_FANOUT_ITEMS = 4_096;
340
- export const MAX_CONCURRENCY = 16;
363
+ /**
364
+ * Upper bound on agents in flight at once.
365
+ *
366
+ * A dispatched agent is a remote call this process waits on, not work it
367
+ * performs, so the ceiling guards against a runaway script rather than against
368
+ * machine capacity. The real throttle belongs to pi-subagents: every managed
369
+ * spawn takes one of its `maxConcurrent` background slots, so a host that wants
370
+ * a narrower fleet narrows it there, once, for every agent it runs.
371
+ */
372
+ export const MAX_CONCURRENCY = 64;
373
+ /**
374
+ * In-flight agents when neither the run nor the caller names a number.
375
+ *
376
+ * Deliberately a constant rather than a function of `hardwareConcurrency`. Core
377
+ * count measures what this process can compute, and dispatching an agent
378
+ * computes nothing here; deriving the default from it fanned a 14-core host out
379
+ * 12 ways and a 4-core host 2 ways for identical, entirely remote work.
380
+ */
381
+ export const DEFAULT_CONCURRENCY = 16;
341
382
  export const MAX_AGENT_RETRIES = 3;
342
- export const DEFAULT_AGENT_TIMEOUT_MS: number | null = 300_000;
383
+ /**
384
+ * Wall clock for one agent when neither the call nor the run names one.
385
+ *
386
+ * It bounds a hang and nothing else. An agent's real budget is the turn, tool
387
+ * and token ceiling pi-subagents applies to it, which a deep call legitimately
388
+ * spends over many minutes; a default an order of magnitude shorter than that
389
+ * budget turned every wide fan-out of real work into a batch of timeouts. Pass
390
+ * `null` to remove the wall clock and leave the host's budgets in sole charge.
391
+ */
392
+ export const DEFAULT_AGENT_TIMEOUT_MS: number | null = 1_800_000;
343
393
  export const MAX_SCRIPT_SYNC_MS = 1_000;
344
394
 
345
395
  // ── Determinism guardrail ────────────────────────────────────────────────────
@@ -865,13 +915,33 @@ async function withTimeout<T>(
865
915
  ms: number | null,
866
916
  label: string,
867
917
  onTimeout?: () => void,
918
+ /**
919
+ * Receives a `restart()` that re-arms the clock from now.
920
+ *
921
+ * The clock is armed at dispatch and restarted when the agent reports that it
922
+ * started, rather than only armed on that report: a report that never arrives
923
+ * must degrade to the old behaviour, not to an agent that can hang forever.
924
+ */
925
+ onArm?: (restart: () => void) => void,
926
+ waitForCleanup?: () => Promise<void>,
868
927
  ): Promise<T> {
869
- if (ms === null) return promise;
928
+ if (ms === null) {
929
+ onArm?.(() => {});
930
+ return promise;
931
+ }
870
932
 
871
933
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
934
+ // The `restart` handed out below outlives this call: the holder keeps it in a
935
+ // closure that the caller may still reach after the race settles. Re-arming
936
+ // then would leave a live timer nothing clears, firing `onTimeout` on a
937
+ // finished agent long after the fact, so settlement makes it inert here
938
+ // rather than relying on every holder to guard its own call.
939
+ let settled = false;
940
+ let timedOut = false;
872
941
 
873
942
  const timeoutPromise = new Promise<never>((_, reject) => {
874
- timeoutId = setTimeout(() => {
943
+ const fire = () => {
944
+ timedOut = true;
875
945
  try {
876
946
  onTimeout?.();
877
947
  } catch {
@@ -884,13 +954,23 @@ async function withTimeout<T>(
884
954
  { recoverable: true },
885
955
  ),
886
956
  );
887
- }, ms);
957
+ };
958
+ timeoutId = setTimeout(fire, ms);
959
+ onArm?.(() => {
960
+ if (settled) return;
961
+ if (timeoutId) clearTimeout(timeoutId);
962
+ timeoutId = setTimeout(fire, ms);
963
+ });
888
964
  });
889
965
 
890
966
  try {
891
967
  return await Promise.race([promise, timeoutPromise]);
892
968
  } finally {
969
+ settled = true;
893
970
  if (timeoutId) clearTimeout(timeoutId);
971
+ // The timeout wins the race immediately, but managed cleanup must settle
972
+ // (or durably fence the run) before script continuation and journal output.
973
+ if (timedOut) await waitForCleanup?.();
894
974
  }
895
975
  }
896
976
 
@@ -1185,19 +1265,38 @@ export async function runWorkflow<T = unknown>(
1185
1265
  };
1186
1266
 
1187
1267
  const agentRunner = options.agent;
1268
+ // The host's pool is the width that can actually be achieved, so it is the
1269
+ // default when it is known and the ceiling when it is not the default.
1270
+ // DEFAULT_CONCURRENCY only covers a peer that published no pool size.
1271
+ const requestedConcurrency = options.concurrency ?? options.hostMaxConcurrent ?? DEFAULT_CONCURRENCY;
1188
1272
  const concurrency = normalizeConcurrency(
1189
- options.concurrency ??
1190
- Math.max(
1191
- 1,
1192
- (globalThis as { navigator?: { hardwareConcurrency?: number } }).navigator?.hardwareConcurrency ?? 8,
1193
- ) - 2,
1273
+ options.hostMaxConcurrent === undefined
1274
+ ? requestedConcurrency
1275
+ : Math.min(requestedConcurrency, options.hostMaxConcurrent),
1194
1276
  );
1277
+ // Only a caller who asked for a specific width can be surprised by the clamp,
1278
+ // and the guidance now tells everything else to omit the field — so the one
1279
+ // caller that set it is exactly the one owed an explanation for the number it
1280
+ // did not get.
1281
+ // Floored, not raw: normalizeConcurrency floors, and reporting 12.5 -> 12 as a
1282
+ // clamp would blame a bound that did not bite.
1283
+ const askedConcurrency = options.concurrency === undefined ? undefined : Math.floor(options.concurrency);
1284
+ const concurrencyClampNotice =
1285
+ askedConcurrency !== undefined && askedConcurrency > concurrency
1286
+ ? `concurrency ${askedConcurrency} clamped to ${concurrency}` +
1287
+ // Name the bound that actually bit. A pool above MAX_CONCURRENCY is not
1288
+ // the reason for the clamp even though it is below what was requested.
1289
+ (concurrency === options.hostMaxConcurrent
1290
+ ? `: the host's subagent pool is ${concurrency}, and dispatching past it only queues`
1291
+ : `: the runtime ceiling is ${MAX_CONCURRENCY}`)
1292
+ : undefined;
1195
1293
 
1196
1294
  const log = (message: unknown) => {
1197
1295
  const text = String(message);
1198
1296
  state.logs.push(text);
1199
1297
  options.onLog?.(text);
1200
1298
  };
1299
+ if (concurrencyClampNotice !== undefined) log(concurrencyClampNotice);
1201
1300
 
1202
1301
  /**
1203
1302
  * The strength table this run will honor, target-checked once at start.
@@ -1584,6 +1683,10 @@ export async function runWorkflow<T = unknown>(
1584
1683
  );
1585
1684
  }
1586
1685
 
1686
+ // Set by withTimeout below, and called by the dispatcher when the
1687
+ // agent leaves the host queue. Undefined until then: a start that
1688
+ // beats the arming simply leaves the full clock ahead of it.
1689
+ let restartTimeout: (() => void) | undefined;
1587
1690
  const runPromise = agentRunner.run(promptText + schemaInstruction + repairFeedback, {
1588
1691
  label,
1589
1692
  sessionName: agentOptions.thread
@@ -1614,9 +1717,21 @@ export async function runWorkflow<T = unknown>(
1614
1717
  onHistory: (history: unknown[]) => {
1615
1718
  options.onAgentHistory?.({ id: deltaKey, label, phase: assignedPhase, history });
1616
1719
  },
1720
+ onRunning: () => {
1721
+ restartTimeout?.();
1722
+ },
1617
1723
  });
1618
1724
  runPromise.catch(() => {});
1619
- let result = await withTimeout(runPromise, timeout, label, () => agentController.abort());
1725
+ let result = await withTimeout(
1726
+ runPromise,
1727
+ timeout,
1728
+ label,
1729
+ () => agentController.abort(),
1730
+ (restart) => {
1731
+ restartTimeout = restart;
1732
+ },
1733
+ options.agent?.waitForCleanup,
1734
+ );
1620
1735
 
1621
1736
  throwIfAborted();
1622
1737
  if (isEmptyTextAgentResult(result, schema)) {
@@ -1683,6 +1798,18 @@ export async function runWorkflow<T = unknown>(
1683
1798
  });
1684
1799
  return result;
1685
1800
  } catch (error) {
1801
+ // A timeout waits for managed cleanup before reaching this catch.
1802
+ // Explicit cancellation may arrive during that await; it wins only
1803
+ // over this recoverable timeout, never over a fatal cleanup fence.
1804
+ if (
1805
+ options.signal?.aborted &&
1806
+ !shared.runFatalController.signal.aborted &&
1807
+ error instanceof WorkflowError &&
1808
+ error.recoverable &&
1809
+ error.code === WorkflowErrorCode.AGENT_TIMEOUT
1810
+ ) {
1811
+ throwIfAborted();
1812
+ }
1686
1813
  if (isAborted()) throw error;
1687
1814
 
1688
1815
  const workflowError = wrapError(error, { agentLabel: label });
@@ -2179,7 +2306,34 @@ export async function runWorkflow<T = unknown>(
2179
2306
 
2180
2307
  let reply: unknown;
2181
2308
  if (options.confirm) {
2182
- reply = await options.confirm(promptText, checkpointOptions);
2309
+ // Host-only cancellation must not affect author options or replay hashes.
2310
+ const signal = options.signal
2311
+ ? AbortSignal.any([options.signal, shared.runFatalController.signal])
2312
+ : shared.runFatalController.signal;
2313
+ const confirm = options.confirm;
2314
+ let onAbort: (() => void) | undefined;
2315
+ try {
2316
+ const cancelled = new Promise<never>((_resolve, reject) => {
2317
+ onAbort = () => {
2318
+ try {
2319
+ throwIfAborted();
2320
+ } catch (error) {
2321
+ reject(error);
2322
+ }
2323
+ };
2324
+ signal.addEventListener("abort", onAbort, { once: true });
2325
+ if (signal.aborted) onAbort();
2326
+ });
2327
+ // Older host callbacks may ignore the optional signal. Still release
2328
+ // the runtime wait, and observe any eventual callback rejection.
2329
+ const answer = Promise.resolve().then(() => {
2330
+ throwIfAborted();
2331
+ return confirm(promptText, checkpointOptions, signal);
2332
+ });
2333
+ reply = await Promise.race([answer, cancelled]);
2334
+ } finally {
2335
+ if (onAbort) signal.removeEventListener("abort", onAbort);
2336
+ }
2183
2337
  } else if (checkpointOptions.headless === "abort") {
2184
2338
  throw new WorkflowError(
2185
2339
  `checkpoint "${promptText}" needs human input but none is available (headless run)`,