@signalridge/pi-workflows 1.3.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Named, deterministic task-graph orchestration for workflow scripts.
3
+ *
4
+ * The script runtime deliberately keeps model/session policy in pi-subagents;
5
+ * this module only schedules script callbacks. Tasks are executed in stable
6
+ * declaration-order layers: all tasks whose dependencies completed form one
7
+ * barrier, and the next layer is discovered only after that barrier settles.
8
+ * That makes dynamic graphs replayable even when child agents finish in a
9
+ * different order.
10
+ */
11
+
12
+ export const MAX_ORCHESTRATION_TASKS = 128;
13
+ export const MAX_ORCHESTRATION_TASK_ID = 128;
14
+ export const MAX_ORCHESTRATION_PHASE = 512;
15
+ export const MAX_ORCHESTRATION_DESCRIPTION = 2_000;
16
+ export const MAX_ORCHESTRATION_RETRIES = 3;
17
+
18
+ export type OrchestrationErrorPolicy = "skip-dependents" | "continue" | "fail-fast";
19
+ export type OrchestrationTaskStatus = "pending" | "running" | "completed" | "failed" | "skipped";
20
+ export type OrchestrationTaskTerminalStatus = Exclude<OrchestrationTaskStatus, "pending" | "running">;
21
+
22
+ export interface OrchestrationTaskContext {
23
+ readonly id: string;
24
+ /** One-based attempt number for this task callback. */
25
+ readonly attempt: number;
26
+ /** Completed values plus null for failed/skipped dependencies. */
27
+ readonly results: Readonly<Record<string, unknown>>;
28
+ /** A detached status snapshot for every declared task. */
29
+ readonly statuses: Readonly<Record<string, OrchestrationTaskStatus>>;
30
+ }
31
+
32
+ export interface OrchestrationTask {
33
+ readonly id: string;
34
+ readonly dependsOn: readonly string[];
35
+ readonly phase?: string;
36
+ readonly description?: string;
37
+ readonly retries: number;
38
+ readonly run: (context: OrchestrationTaskContext) => unknown | Promise<unknown>;
39
+ }
40
+
41
+ export interface OrchestrationOptions {
42
+ readonly onError: OrchestrationErrorPolicy;
43
+ }
44
+
45
+ export interface OrchestrationTaskResult {
46
+ readonly id: string;
47
+ readonly status: OrchestrationTaskTerminalStatus;
48
+ /** Failed and skipped tasks expose null so downstream JSON is stable. */
49
+ readonly value: unknown;
50
+ readonly attempts: number;
51
+ readonly error?: string;
52
+ }
53
+
54
+ export interface OrchestrationResult {
55
+ /** Every task id appears once; failed/skipped values are null. */
56
+ readonly results: Record<string, unknown>;
57
+ /** Every task id appears once in declaration order. */
58
+ readonly tasks: Record<string, OrchestrationTaskResult>;
59
+ }
60
+
61
+ export interface OrchestrationTaskEvent {
62
+ readonly type: "task";
63
+ readonly [key: string]: unknown;
64
+ readonly stage: "start" | "retry" | "end" | "skip";
65
+ readonly taskId: string;
66
+ readonly phase?: string;
67
+ readonly attempt: number;
68
+ readonly status?: OrchestrationTaskTerminalStatus;
69
+ readonly error?: string;
70
+ }
71
+
72
+ export interface OrchestrationHooks {
73
+ /** Throw when the enclosing workflow has been cancelled. */
74
+ readonly throwIfAborted?: () => void;
75
+ /** Return true for infrastructure/fatal errors that must escape the graph. */
76
+ readonly isFatal?: (error: unknown) => boolean;
77
+ /** Render ordinary task failures into bounded diagnostics. */
78
+ readonly formatError?: (error: unknown) => string;
79
+ /** Observe task transitions; listener failures never affect scheduling. */
80
+ readonly onEvent?: (event: OrchestrationTaskEvent) => void;
81
+ }
82
+
83
+ interface RawRecord {
84
+ readonly [key: string]: unknown;
85
+ }
86
+
87
+ interface MutableTaskResult {
88
+ readonly id: string;
89
+ status: OrchestrationTaskStatus;
90
+ value: unknown;
91
+ attempts: number;
92
+ error?: string;
93
+ errorValue?: unknown;
94
+ }
95
+
96
+ function isRecord(value: unknown): value is RawRecord {
97
+ return typeof value === "object" && value !== null && !Array.isArray(value);
98
+ }
99
+
100
+ function readBoundedString(value: unknown, label: string, limit: number): string {
101
+ if (typeof value !== "string" || value.trim().length === 0) {
102
+ throw new TypeError(`${label} must be a non-empty string`);
103
+ }
104
+ const normalized = value.trim();
105
+ if (normalized.length > limit) throw new TypeError(`${label} exceeds ${limit} characters`);
106
+ return normalized;
107
+ }
108
+
109
+ function readDependencies(raw: RawRecord, label: string): string[] {
110
+ const hasCamel = Object.hasOwn(raw, "dependsOn");
111
+ const hasSnake = Object.hasOwn(raw, "depends_on");
112
+ if (hasCamel && hasSnake) throw new TypeError(`${label} cannot contain both dependsOn and depends_on`);
113
+ const value = hasCamel ? raw.dependsOn : hasSnake ? raw.depends_on : undefined;
114
+ if (value === undefined) return [];
115
+ if (!Array.isArray(value)) throw new TypeError(`${label}.dependsOn must be an array`);
116
+ const dependencies: string[] = [];
117
+ for (const [index, dependency] of value.entries()) {
118
+ const normalized = readBoundedString(dependency, `${label}.dependsOn[${index}]`, MAX_ORCHESTRATION_TASK_ID);
119
+ if (dependencies.includes(normalized)) throw new TypeError(`${label}.dependsOn repeats "${normalized}"`);
120
+ dependencies.push(normalized);
121
+ }
122
+ return dependencies;
123
+ }
124
+
125
+ function readRetries(value: unknown, label: string): number {
126
+ if (value === undefined) return 0;
127
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_ORCHESTRATION_RETRIES) {
128
+ throw new TypeError(`${label}.retries must be an integer from 0 to ${MAX_ORCHESTRATION_RETRIES}`);
129
+ }
130
+ return value;
131
+ }
132
+
133
+ function assertKnownKeys(raw: RawRecord, label: string): void {
134
+ const allowed = new Set(["id", "dependsOn", "depends_on", "phase", "description", "retries", "run"]);
135
+ for (const key of Object.keys(raw)) {
136
+ if (!allowed.has(key)) throw new TypeError(`${label} contains unsupported field "${key}"`);
137
+ }
138
+ }
139
+
140
+ /** Validate and normalize a runtime task list without executing callbacks. */
141
+ export function normalizeOrchestration(
142
+ rawTasks: unknown,
143
+ rawOptions: unknown = undefined,
144
+ ): { tasks: OrchestrationTask[]; options: OrchestrationOptions } {
145
+ if (!Array.isArray(rawTasks)) throw new TypeError("orchestrate() expects an array of task objects");
146
+ if (rawTasks.length > MAX_ORCHESTRATION_TASKS) {
147
+ throw new TypeError(`orchestrate() accepts at most ${MAX_ORCHESTRATION_TASKS} tasks`);
148
+ }
149
+
150
+ const tasks: OrchestrationTask[] = [];
151
+ const ids = new Set<string>();
152
+ for (const [index, rawValue] of rawTasks.entries()) {
153
+ const label = `orchestrate() task[${index}]`;
154
+ if (!isRecord(rawValue)) throw new TypeError(`${label} must be an object`);
155
+ assertKnownKeys(rawValue, label);
156
+ const id = readBoundedString(rawValue.id, `${label}.id`, MAX_ORCHESTRATION_TASK_ID);
157
+ if (id === "__proto__" || id === "constructor" || id === "prototype") {
158
+ throw new TypeError(`${label}.id uses a reserved key name`);
159
+ }
160
+ if (ids.has(id)) throw new TypeError(`orchestrate() contains duplicate task id "${id}"`);
161
+ ids.add(id);
162
+
163
+ if (typeof rawValue.run !== "function") throw new TypeError(`${label}.run must be a function`);
164
+ const dependsOn = readDependencies(rawValue, label);
165
+ const phase =
166
+ rawValue.phase === undefined
167
+ ? undefined
168
+ : readBoundedString(rawValue.phase, `${label}.phase`, MAX_ORCHESTRATION_PHASE);
169
+ const description =
170
+ rawValue.description === undefined
171
+ ? undefined
172
+ : readBoundedString(rawValue.description, `${label}.description`, MAX_ORCHESTRATION_DESCRIPTION);
173
+ tasks.push({
174
+ id,
175
+ dependsOn,
176
+ ...(phase === undefined ? {} : { phase }),
177
+ ...(description === undefined ? {} : { description }),
178
+ retries: readRetries(rawValue.retries, label),
179
+ run: rawValue.run as OrchestrationTask["run"],
180
+ });
181
+ }
182
+
183
+ const taskIds = new Set(tasks.map((task) => task.id));
184
+ for (const task of tasks) {
185
+ for (const dependency of task.dependsOn) {
186
+ if (!taskIds.has(dependency)) {
187
+ throw new TypeError(`orchestrate() task "${task.id}" depends on unknown task "${dependency}"`);
188
+ }
189
+ if (dependency === task.id) throw new TypeError(`orchestrate() task "${task.id}" cannot depend on itself`);
190
+ }
191
+ }
192
+ assertAcyclic(tasks);
193
+
194
+ let onError: OrchestrationErrorPolicy = "skip-dependents";
195
+ if (rawOptions !== undefined) {
196
+ if (!isRecord(rawOptions)) throw new TypeError("orchestrate() options must be an object");
197
+ for (const key of Object.keys(rawOptions)) {
198
+ if (key !== "onError") throw new TypeError(`orchestrate() options contains unsupported field "${key}"`);
199
+ }
200
+ if (rawOptions.onError !== undefined) {
201
+ if (
202
+ rawOptions.onError !== "skip-dependents" &&
203
+ rawOptions.onError !== "continue" &&
204
+ rawOptions.onError !== "fail-fast"
205
+ ) {
206
+ throw new TypeError('orchestrate() options.onError must be "skip-dependents", "continue", or "fail-fast"');
207
+ }
208
+ onError = rawOptions.onError;
209
+ }
210
+ }
211
+ return { tasks, options: { onError } };
212
+ }
213
+
214
+ function assertAcyclic(tasks: readonly OrchestrationTask[]): void {
215
+ const indegree = new Map<string, number>();
216
+ const dependents = new Map<string, string[]>();
217
+ for (const task of tasks) {
218
+ indegree.set(task.id, task.dependsOn.length);
219
+ for (const dependency of task.dependsOn) {
220
+ const list = dependents.get(dependency) ?? [];
221
+ list.push(task.id);
222
+ dependents.set(dependency, list);
223
+ }
224
+ }
225
+ const queue = tasks.filter((task) => indegree.get(task.id) === 0).map((task) => task.id);
226
+ let visited = 0;
227
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
228
+ const id = queue[cursor];
229
+ visited += 1;
230
+ for (const dependent of dependents.get(id) ?? []) {
231
+ const next = (indegree.get(dependent) ?? 0) - 1;
232
+ indegree.set(dependent, next);
233
+ if (next === 0) queue.push(dependent);
234
+ }
235
+ }
236
+ if (visited !== tasks.length) throw new TypeError("orchestrate() task dependencies contain a cycle");
237
+ }
238
+
239
+ function terminal(status: OrchestrationTaskStatus | undefined): status is OrchestrationTaskTerminalStatus {
240
+ return status === "completed" || status === "failed" || status === "skipped";
241
+ }
242
+
243
+ function defaultFormatError(error: unknown): string {
244
+ return error instanceof Error ? error.message : String(error);
245
+ }
246
+
247
+ function emit(hooks: OrchestrationHooks, event: OrchestrationTaskEvent): void {
248
+ try {
249
+ hooks.onEvent?.(event);
250
+ } catch {
251
+ // Observers are not part of execution correctness.
252
+ }
253
+ }
254
+
255
+ function snapshotContext(
256
+ task: OrchestrationTask,
257
+ attempt: number,
258
+ values: Record<string, unknown>,
259
+ statuses: Record<string, OrchestrationTaskStatus>,
260
+ ): OrchestrationTaskContext {
261
+ let detachedResults: Record<string, unknown>;
262
+ try {
263
+ detachedResults = structuredClone(values);
264
+ } catch (error: unknown) {
265
+ throw new TypeError(
266
+ `orchestrate() task "${task.id}" results cannot be detached: ${error instanceof Error ? error.message : String(error)}`,
267
+ );
268
+ }
269
+ return Object.freeze({
270
+ id: task.id,
271
+ attempt,
272
+ results: Object.freeze(detachedResults),
273
+ statuses: Object.freeze({ ...statuses }),
274
+ });
275
+ }
276
+
277
+ /** Execute normalized tasks in deterministic dependency layers. */
278
+ export async function executeOrchestration(
279
+ tasks: readonly OrchestrationTask[],
280
+ options: OrchestrationOptions = { onError: "skip-dependents" },
281
+ hooks: OrchestrationHooks = {},
282
+ ): Promise<OrchestrationResult> {
283
+ if (tasks.length > MAX_ORCHESTRATION_TASKS) {
284
+ throw new TypeError(`orchestrate() accepts at most ${MAX_ORCHESTRATION_TASKS} tasks`);
285
+ }
286
+ const pending = new Set(tasks.map((task) => task.id));
287
+ const values: Record<string, unknown> = {};
288
+ const statuses: Record<string, OrchestrationTaskStatus> = {};
289
+ const records = new Map<string, MutableTaskResult>();
290
+ for (const task of tasks) {
291
+ statuses[task.id] = "pending";
292
+ // Keep pending tasks out of callback snapshots. The final `results` map is
293
+ // filled separately so callers still receive one stable key per task.
294
+ records.set(task.id, { id: task.id, status: "pending", value: null, attempts: 0 });
295
+ }
296
+
297
+ const markSkipped = (task: OrchestrationTask, reason: string): void => {
298
+ const record = records.get(task.id);
299
+ if (!record || !pending.has(task.id)) return;
300
+ pending.delete(task.id);
301
+ record.status = "skipped";
302
+ record.value = null;
303
+ record.error = reason;
304
+ statuses[task.id] = "skipped";
305
+ values[task.id] = null;
306
+ emit(hooks, {
307
+ type: "task",
308
+ stage: "skip",
309
+ taskId: task.id,
310
+ ...(task.phase ? { phase: task.phase } : {}),
311
+ attempt: 0,
312
+ status: "skipped",
313
+ error: reason,
314
+ });
315
+ };
316
+
317
+ while (pending.size > 0) {
318
+ hooks.throwIfAborted?.();
319
+
320
+ if (options.onError !== "continue") {
321
+ let propagatedSkip: boolean;
322
+ do {
323
+ propagatedSkip = false;
324
+ for (const task of tasks) {
325
+ if (!pending.has(task.id)) continue;
326
+ const failedDependency = task.dependsOn.find((dependency) => {
327
+ const status = statuses[dependency];
328
+ return status === "failed" || status === "skipped";
329
+ });
330
+ if (failedDependency !== undefined) {
331
+ markSkipped(task, `dependency "${failedDependency}" did not complete`);
332
+ propagatedSkip = true;
333
+ }
334
+ }
335
+ } while (propagatedSkip);
336
+ if (pending.size === 0) break;
337
+ }
338
+
339
+ const ready = tasks.filter((task) => {
340
+ if (!pending.has(task.id)) return false;
341
+ return task.dependsOn.every((dependency) => {
342
+ const status = statuses[dependency];
343
+ return options.onError === "continue" ? terminal(status) : status === "completed";
344
+ });
345
+ });
346
+ if (ready.length === 0) {
347
+ throw new Error("orchestrate() could not make progress; check task dependencies and failure policy");
348
+ }
349
+
350
+ for (const task of ready) {
351
+ const record = records.get(task.id);
352
+ if (!record) continue;
353
+ record.status = "running";
354
+ statuses[task.id] = "running";
355
+ emit(hooks, {
356
+ type: "task",
357
+ stage: "start",
358
+ taskId: task.id,
359
+ ...(task.phase ? { phase: task.phase } : {}),
360
+ attempt: 1,
361
+ });
362
+ }
363
+
364
+ const outcomes = await Promise.all(
365
+ ready.map(async (task): Promise<{ task: OrchestrationTask; fatal?: { error: unknown } }> => {
366
+ const record = records.get(task.id);
367
+ if (!record) return { task };
368
+ for (let attempt = 1; attempt <= task.retries + 1; attempt += 1) {
369
+ record.attempts = attempt;
370
+ try {
371
+ hooks.throwIfAborted?.();
372
+ const value = await task.run(snapshotContext(task, attempt, values, statuses));
373
+ hooks.throwIfAborted?.();
374
+ pending.delete(task.id);
375
+ record.status = "completed";
376
+ record.value = value === undefined ? null : value;
377
+ statuses[task.id] = "completed";
378
+ values[task.id] = record.value;
379
+ emit(hooks, {
380
+ type: "task",
381
+ stage: "end",
382
+ taskId: task.id,
383
+ ...(task.phase ? { phase: task.phase } : {}),
384
+ attempt,
385
+ status: "completed",
386
+ });
387
+ return { task };
388
+ } catch (error: unknown) {
389
+ const fatal = hooks.isFatal?.(error) ?? false;
390
+ const message = (hooks.formatError ?? defaultFormatError)(error);
391
+ record.error = message;
392
+ record.errorValue = error;
393
+ if (fatal) {
394
+ pending.delete(task.id);
395
+ record.status = "failed";
396
+ record.value = null;
397
+ statuses[task.id] = "failed";
398
+ values[task.id] = null;
399
+ emit(hooks, {
400
+ type: "task",
401
+ stage: "end",
402
+ taskId: task.id,
403
+ ...(task.phase ? { phase: task.phase } : {}),
404
+ attempt,
405
+ status: "failed",
406
+ error: message,
407
+ });
408
+ return { task, fatal: { error } };
409
+ }
410
+ if (attempt <= task.retries) {
411
+ emit(hooks, {
412
+ type: "task",
413
+ stage: "retry",
414
+ taskId: task.id,
415
+ ...(task.phase ? { phase: task.phase } : {}),
416
+ attempt,
417
+ error: message,
418
+ });
419
+ continue;
420
+ }
421
+ pending.delete(task.id);
422
+ record.status = "failed";
423
+ record.value = null;
424
+ statuses[task.id] = "failed";
425
+ values[task.id] = null;
426
+ emit(hooks, {
427
+ type: "task",
428
+ stage: "end",
429
+ taskId: task.id,
430
+ ...(task.phase ? { phase: task.phase } : {}),
431
+ attempt,
432
+ status: "failed",
433
+ error: message,
434
+ });
435
+ return { task };
436
+ }
437
+ }
438
+ return { task };
439
+ }),
440
+ );
441
+
442
+ const fatal = outcomes.find((outcome) => outcome.fatal !== undefined)?.fatal;
443
+ if (fatal) throw fatal.error;
444
+ if (options.onError === "fail-fast") {
445
+ const failed = ready.find((task) => records.get(task.id)?.status === "failed");
446
+ if (failed) {
447
+ const record = records.get(failed.id);
448
+ throw record?.errorValue ?? new Error(record?.error ?? `orchestrate() task "${failed.id}" failed`);
449
+ }
450
+ }
451
+ }
452
+
453
+ const taskResults: Record<string, OrchestrationTaskResult> = {};
454
+ for (const task of tasks) {
455
+ const record = records.get(task.id);
456
+ if (!record || !terminal(record.status)) continue;
457
+ taskResults[task.id] = {
458
+ id: task.id,
459
+ status: record.status,
460
+ value: record.value,
461
+ attempts: record.attempts,
462
+ ...(record.error === undefined ? {} : { error: record.error }),
463
+ };
464
+ }
465
+ const outputResults: Record<string, unknown> = {};
466
+ for (const task of tasks) outputResults[task.id] = values[task.id] ?? null;
467
+ return { results: outputResults, tasks: taskResults };
468
+ }
469
+
470
+ /** Validate and execute a runtime task list in one call. */
471
+ export async function runOrchestration(
472
+ rawTasks: unknown,
473
+ rawOptions: unknown,
474
+ hooks: OrchestrationHooks = {},
475
+ ): Promise<OrchestrationResult> {
476
+ const normalized = normalizeOrchestration(rawTasks, rawOptions);
477
+ return executeOrchestration(normalized.tasks, normalized.options, hooks);
478
+ }
package/src/rpc-client.ts CHANGED
@@ -1,15 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type {
3
+ ManagedRoutingPolicy,
3
4
  ManagedSpawnResponse as ProtocolManagedSpawnResponse,
4
5
  ManagedTerminalSnapshot as ProtocolManagedTerminalSnapshot,
5
- WorkflowTier,
6
6
  } from "@signalridge/pi-subagents-protocol";
7
7
  import {
8
8
  PROTOCOL_VERSION,
9
9
  parseManagedSpawnResponse,
10
10
  parseProtocolPing,
11
11
  requiredCapabilitiesMatch,
12
- workflowTierCapabilityMatch,
13
12
  } from "@signalridge/pi-subagents-protocol";
14
13
  import type { WorkflowOwner } from "./journal.js";
15
14
 
@@ -18,11 +17,8 @@ export interface DispatchTask {
18
17
  subagent_type: string;
19
18
  prompt: string;
20
19
  description: string;
21
- tier?: WorkflowTier;
22
- /** Exact provider/model reference; resolution remains inside pi-subagents. */
23
- model?: string;
24
- /** Optional thinking suffix/override resolved by pi-subagents. */
25
- thinking?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "off";
20
+ /** Agent-tier key, from the host's own catalogue. The only model policy here. */
21
+ tier?: string;
26
22
  /** Per-call worktree request owned by pi-subagents. */
27
23
  isolation?: "worktree";
28
24
  /** Forwarded toolset hint (e.g. "web-research") for pi-subagents to augment tools. */
@@ -105,9 +101,11 @@ function callRpc<T>(
105
101
 
106
102
  export const REQUIRED_SUBAGENTS_PROTOCOL = PROTOCOL_VERSION;
107
103
  export const PROTOCOL_DIAGNOSTIC =
108
- "@signalridge/pi-workflows requires @signalridge/pi-subagents protocol v3 with managedSpawn, lifecycleOwner, ownedStop, ownedQuiescence, workflowTiers, and managedPolicy capabilities. " +
104
+ `@signalridge/pi-workflows requires @signalridge/pi-subagents protocol v${PROTOCOL_VERSION} with managedSpawn, lifecycleOwner, ownedStop, childContext, ownedQuiescence, agentTiers, and managedPolicy, plus its Agent-tier routing policy. ` +
109
105
  "Install @signalridge/pi-subagents and @signalridge/pi-workflows exactly once; Pi loads both from their configured pi.extensions manifests.";
110
106
 
107
+ const CAPABILITY_DIAGNOSTIC = `subagents protocol v${PROTOCOL_VERSION} is required, with every capability and an Agent-tier routing policy`;
108
+
111
109
  export const CHILD_CONTEXT_QUERY_TIMEOUT_MS = 250;
112
110
 
113
111
  /**
@@ -169,7 +167,17 @@ export function queryChildSessionContextImmediate(events: WorkflowEventBus): boo
169
167
  }
170
168
 
171
169
  /** Verify the separately configured pi-subagents package before workflow execution. */
172
- export async function checkManagedSpawnProtocol(events: WorkflowEventBus, signal?: AbortSignal): Promise<void> {
170
+ export interface ManagedProtocolCheck {
171
+ /** The host's Agent-tier catalogue, as of this check. */
172
+ routingPolicy: ManagedRoutingPolicy;
173
+ /** Whole-catalogue identity; used only where a per-tier identity cannot be. */
174
+ routingPolicyFingerprint: string;
175
+ }
176
+
177
+ export async function checkManagedSpawnProtocol(
178
+ events: WorkflowEventBus,
179
+ signal?: AbortSignal,
180
+ ): Promise<ManagedProtocolCheck> {
173
181
  const id = requestId();
174
182
  const reply = await new Promise<unknown>((resolve, reject) => {
175
183
  const replyChannel = `subagents:rpc:ping:reply:${id}`;
@@ -228,57 +236,40 @@ export async function checkManagedSpawnProtocol(events: WorkflowEventBus, signal
228
236
  let ping: ReturnType<typeof parseProtocolPing>;
229
237
  try {
230
238
  ping = parseProtocolPing(reply);
231
- } catch {
232
- if (
233
- !isRecord(reply) ||
234
- typeof reply.version !== "number" ||
235
- !isRecord(reply.capabilities) ||
236
- reply.version < REQUIRED_SUBAGENTS_PROTOCOL ||
237
- reply.capabilities.managedSpawn !== true ||
238
- reply.capabilities.lifecycleOwner !== true ||
239
- reply.capabilities.ownedStop !== true ||
240
- reply.capabilities.ownedQuiescence !== true ||
241
- reply.capabilities.managedPolicy !== true
242
- ) {
243
- throw new Error(
244
- `subagents protocol v${REQUIRED_SUBAGENTS_PROTOCOL} with managed spawning, owned stop, owned quiescence, and managed policy capabilities is required`,
245
- );
239
+ } catch (error: unknown) {
240
+ // Anything shaped like a ping is a peer that is present but not v4-complete,
241
+ // and the actionable answer is the same in every such case: install a
242
+ // matching pair. A reply that is not a ping at all gets its own message,
243
+ // because "upgrade the peer" would be the wrong thing to go do.
244
+ if (!isRecord(reply) || typeof reply.version !== "number") {
245
+ throw new Error("subagents protocol ping did not return a valid version/capability envelope");
246
246
  }
247
- throw new Error("subagents protocol ping did not return a valid version/capability envelope");
247
+ throw new Error(`${CAPABILITY_DIAGNOSTIC} (${error instanceof Error ? error.message : String(error)})`);
248
248
  }
249
+ // Every v4 capability is required, so one check covers them all. The parser
250
+ // has already rejected a ping without a well-formed routing policy.
249
251
  if (ping.version < REQUIRED_SUBAGENTS_PROTOCOL || !requiredCapabilitiesMatch(ping.capabilities)) {
250
- throw new Error(
251
- `subagents protocol v${REQUIRED_SUBAGENTS_PROTOCOL} with managed spawning, owned stop, and owned quiescence capability is required`,
252
- );
253
- }
254
- if (!workflowTierCapabilityMatch(ping.capabilities)) {
255
- throw new Error("subagents workflow tier capability is required before tiered managed spawning");
256
- }
257
- if (ping.capabilities.managedPolicy !== true) {
258
- throw new Error("subagents managed policy capability is required before policy-bearing managed spawning");
252
+ throw new Error(CAPABILITY_DIAGNOSTIC);
259
253
  }
254
+ return {
255
+ routingPolicy: ping.routingPolicy.policy,
256
+ routingPolicyFingerprint: ping.routingPolicy.fingerprint,
257
+ };
260
258
  }
261
259
 
262
260
  export type ManagedTerminalSnapshot = ProtocolManagedTerminalSnapshot;
263
261
  export type ManagedSpawnResponse = ProtocolManagedSpawnResponse;
264
262
 
265
263
  export interface ManagedSpawnClient {
266
- /** String remains accepted for deterministic legacy fixtures; protocol v3 returns ManagedSpawnResponse. */
267
- /**
268
- * The fourth argument is the current attempt for protocol-v3 clients. The
269
- * optional fifth argument is retained only for old in-process test/fixture
270
- * clients that used the former context, attemptId shape; it is never a
271
- * runtime context and is not sent over RPC.
272
- */
264
+ /** Managed spawn returns a structured response. */
273
265
  spawn(
274
266
  task: DispatchTask,
275
267
  runId: string,
276
268
  nodeId: string,
277
269
  attemptId?: string,
278
- legacyAttemptId?: string,
279
270
  signal?: AbortSignal,
280
271
  ): Promise<ManagedSpawnResponse | string>;
281
- /** Unscoped v2 stop, retained for existing callers. */
272
+ /** Unscoped stop for non-managed callers. */
282
273
  stop(agentId: string): Promise<void>;
283
274
  /** Owner-scoped v3 stop used by workflow lifecycle operations. */
284
275
  stopOwned?(agentId: string, owner: WorkflowOwner): Promise<void>;
@@ -290,8 +281,8 @@ export interface ManagedSpawnClient {
290
281
  timeoutMs?: number,
291
282
  owners?: WorkflowOwner[],
292
283
  ): Promise<{ settled: boolean; pending: string[] }>;
293
- /** Optional startup capability check; workflow execution remains event-bus-only. */
294
- checkProtocol?: () => Promise<void>;
284
+ /** Fresh capability and routing-policy check; workflow execution remains event-bus-only. */
285
+ checkProtocol?: () => Promise<ManagedProtocolCheck>;
295
286
  }
296
287
 
297
288
  /** Event-bus-only client for the additive pi-subagents managed protocol. */
@@ -320,7 +311,7 @@ export function createManagedSpawnClient(
320
311
  * error for the same key with a different fingerprint. Same key + same
321
312
  * fingerprint still reuses the persisted tombstone (per-call cache).
322
313
  */
323
- async spawn(task, runId, nodeId, attemptId, _legacyAttemptId, signal) {
314
+ async spawn(task, runId, nodeId, attemptId, signal) {
324
315
  const effectiveAttemptId = attemptId ?? `attempt-1`;
325
316
  const spawnKey = `${runId}/${nodeId}/${effectiveAttemptId}`;
326
317
  const requestSignal =
@@ -339,8 +330,6 @@ export function createManagedSpawnClient(
339
330
  ...(task.toolset === undefined ? {} : { toolset: task.toolset }),
340
331
  ...(task.thread === undefined ? {} : { thread: task.thread }),
341
332
  ...(task.excludeTools === undefined ? {} : { excludeTools: task.excludeTools }),
342
- ...(task.model === undefined ? {} : { model: task.model }),
343
- ...(task.thinking === undefined ? {} : { thinking: task.thinking }),
344
333
  ...(task.isolation === undefined ? {} : { isolation: task.isolation }),
345
334
  owner: { extension: "pi-workflows", runId, nodeId, attemptId: effectiveAttemptId },
346
335
  },