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