@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/CHANGELOG.md +149 -0
- package/README.md +25 -3
- package/package.json +4 -4
- package/skills/workflow-authoring/SKILL.md +23 -4
- package/skills/workflow-authoring/examples/defensive-json-parsing.js +1 -1
- package/skills/workflow-authoring/examples/phased-budgets.js +1 -1
- package/skills/workflow-authoring/examples/saved-nested-workflows.js +1 -1
- package/skills/workflow-authoring/examples/structured-output.js +1 -1
- package/skills/workflow-authoring/references/capabilities.md +4 -4
- package/skills/workflow-authoring/references/capability-details.md +12 -6
- package/skills/workflow-authoring/references/lifecycle.md +5 -1
- package/skills/workflow-authoring/references/review.md +4 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +6 -4
- package/src/capabilities.ts +15 -5
- package/src/engine.ts +210 -33
- package/src/index.ts +72 -32
- package/src/rpc-client.ts +22 -4
- package/src/runtime.ts +167 -13
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
+
undefined,
|
|
394
407
|
timeoutMs + 1_000,
|
|
395
408
|
);
|
|
396
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
}
|
|
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.
|
|
1190
|
-
|
|
1191
|
-
|
|
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(
|
|
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
|
-
|
|
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)`,
|