@signalridge/pi-workflows 1.4.0 → 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.
- package/CHANGELOG.md +135 -0
- package/README.md +54 -12
- package/package.json +3 -3
- package/skills/workflow-authoring/SKILL.md +11 -6
- package/skills/workflow-authoring/references/capabilities.md +3 -3
- package/skills/workflow-authoring/references/capability-details.md +7 -5
- package/skills/workflow-authoring/references/debugging.md +2 -2
- package/skills/workflow-authoring/references/registry-ownership.md +6 -4
- package/skills/workflow-authoring/references/review.md +5 -2
- package/skills/workflow-authoring/references/runtime.md +3 -3
- 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 +11 -4
- package/src/engine.ts +238 -58
- package/src/index.ts +292 -150
- package/src/journal.ts +292 -253
- package/src/rpc-client.ts +37 -48
- package/src/runtime.ts +176 -44
- package/src/ui.ts +29 -4
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";
|
|
@@ -85,6 +88,8 @@ export interface ScriptStartOptions {
|
|
|
85
88
|
concurrency?: number;
|
|
86
89
|
agentRetries?: number;
|
|
87
90
|
tokenBudget?: number | null;
|
|
91
|
+
/** True for a script this package ships; see WorkflowRunOptions.shippedScript. */
|
|
92
|
+
shippedScript?: boolean;
|
|
88
93
|
agentTimeoutMs?: number | null;
|
|
89
94
|
toolset?: string;
|
|
90
95
|
excludeTools?: string[];
|
|
@@ -100,7 +105,13 @@ export interface ScriptStartOptions {
|
|
|
100
105
|
timeoutMs?: number;
|
|
101
106
|
},
|
|
102
107
|
) => Promise<unknown>;
|
|
103
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Resolve a nested `workflow(name)` reference. The result reports whether the
|
|
110
|
+
* script it returned ships with this package, because that decides how the
|
|
111
|
+
* child frame treats a tier name the host does not define — see
|
|
112
|
+
* `WorkflowRunOptions.shippedScript`.
|
|
113
|
+
*/
|
|
114
|
+
loadSavedWorkflow?: (name: string) => { script: string; shippedScript?: boolean } | undefined;
|
|
104
115
|
mainModel?: string;
|
|
105
116
|
}
|
|
106
117
|
|
|
@@ -140,7 +151,14 @@ export interface ScriptRunState {
|
|
|
140
151
|
/** Frozen run parameters — resume reuses the original values, ignoring new budget/toolset overrides. */
|
|
141
152
|
frozenOptions: Pick<
|
|
142
153
|
ScriptStartOptions,
|
|
143
|
-
|
|
154
|
+
| "tokenBudget"
|
|
155
|
+
| "maxAgents"
|
|
156
|
+
| "concurrency"
|
|
157
|
+
| "agentRetries"
|
|
158
|
+
| "agentTimeoutMs"
|
|
159
|
+
| "toolset"
|
|
160
|
+
| "shippedScript"
|
|
161
|
+
| "excludeTools"
|
|
144
162
|
>;
|
|
145
163
|
/** Abort controller for this run's script execution. */
|
|
146
164
|
controller: AbortController;
|
|
@@ -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 } : {}),
|
|
@@ -259,6 +294,9 @@ export class WorkflowEngine {
|
|
|
259
294
|
Set<{ resolve: (run: ScriptRun) => void; reject: (e: unknown) => void; settled: boolean; cleanup: () => void }>
|
|
260
295
|
>();
|
|
261
296
|
private unsubscribeLifecycle: () => void;
|
|
297
|
+
private readonly protocolGate: () => Promise<ManagedProtocolCheck>;
|
|
298
|
+
private readonly disposeController = new AbortController();
|
|
299
|
+
private disposed = false;
|
|
262
300
|
private lifecyclePaused = false;
|
|
263
301
|
private branchQuiescePromise: Promise<WorkflowQuiesceResult> | undefined;
|
|
264
302
|
private recoveryBranchGeneration = 0;
|
|
@@ -273,10 +311,37 @@ export class WorkflowEngine {
|
|
|
273
311
|
private readonly client: ManagedSpawnClient,
|
|
274
312
|
private readonly journal: JournalWriter,
|
|
275
313
|
private readonly readEntries: () => readonly SessionEntryLike[] = () => [],
|
|
314
|
+
protocolGate?: () => Promise<ManagedProtocolCheck>,
|
|
276
315
|
) {
|
|
316
|
+
this.protocolGate =
|
|
317
|
+
protocolGate ??
|
|
318
|
+
(() => this.client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable")));
|
|
277
319
|
this.unsubscribeLifecycle = this.attachLifecycle();
|
|
278
320
|
}
|
|
279
321
|
|
|
322
|
+
private async awaitProtocol(): Promise<ManagedProtocolCheck> {
|
|
323
|
+
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
324
|
+
let rejectDisposed: ((reason?: unknown) => void) | undefined;
|
|
325
|
+
const disposed = new Promise<never>((_, reject) => {
|
|
326
|
+
rejectDisposed = reject;
|
|
327
|
+
});
|
|
328
|
+
const onDisposed = () => rejectDisposed?.(new WorkflowEngineDisposedError());
|
|
329
|
+
this.disposeController.signal.addEventListener("abort", onDisposed, { once: true });
|
|
330
|
+
let result: ManagedProtocolCheck;
|
|
331
|
+
try {
|
|
332
|
+
result = await Promise.race([this.protocolGate(), disposed]);
|
|
333
|
+
} finally {
|
|
334
|
+
this.disposeController.signal.removeEventListener("abort", onDisposed);
|
|
335
|
+
}
|
|
336
|
+
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Whether this engine can still accept new workflow executions. */
|
|
341
|
+
isDisposed(): boolean {
|
|
342
|
+
return this.disposed;
|
|
343
|
+
}
|
|
344
|
+
|
|
280
345
|
private attachLifecycle(): () => void {
|
|
281
346
|
const unsubs = [
|
|
282
347
|
"subagents:created",
|
|
@@ -295,7 +360,13 @@ export class WorkflowEngine {
|
|
|
295
360
|
}
|
|
296
361
|
|
|
297
362
|
private onLifecycle(eventName: string, raw: unknown): void {
|
|
298
|
-
if (
|
|
363
|
+
if (
|
|
364
|
+
eventName !== "subagents:created" &&
|
|
365
|
+
eventName !== "subagents:started" &&
|
|
366
|
+
eventName !== "subagents:completed" &&
|
|
367
|
+
eventName !== "subagents:failed"
|
|
368
|
+
)
|
|
369
|
+
return;
|
|
299
370
|
if (!raw || typeof raw !== "object") return;
|
|
300
371
|
const data = raw as Record<string, unknown>;
|
|
301
372
|
const agentId = typeof data.id === "string" ? data.id : typeof data.agentId === "string" ? data.agentId : undefined;
|
|
@@ -319,6 +390,40 @@ export class WorkflowEngine {
|
|
|
319
390
|
};
|
|
320
391
|
const state = this.runs.get(owner.runId);
|
|
321
392
|
if (state?.run.status !== "running" || state.lifecycleSuspended || state.controller.signal.aborted) return;
|
|
393
|
+
const callNodeId = callIndexFromOwnerNode(owner.nodeId);
|
|
394
|
+
if (callNodeId === undefined) return;
|
|
395
|
+
|
|
396
|
+
// Validate ownership and the current attempt before accepting any identity
|
|
397
|
+
// from the host. A delayed lifecycle fact from an older attempt must not
|
|
398
|
+
// overwrite the tier identity of the current attempt.
|
|
399
|
+
const spawnKey = `${owner.runId}/${owner.nodeId}/${owner.attemptId}`;
|
|
400
|
+
const pending = state.pendingSpawns.get(spawnKey);
|
|
401
|
+
const waiter = state.agentWaiters.get(agentId);
|
|
402
|
+
const waiterMatches = Boolean(
|
|
403
|
+
waiter &&
|
|
404
|
+
!waiter.settled &&
|
|
405
|
+
waiter.executionGeneration === state.executionGeneration &&
|
|
406
|
+
owner.nodeId === `call-${waiter.callIndex}` &&
|
|
407
|
+
owner.attemptId === `attempt-${waiter.generation}`,
|
|
408
|
+
);
|
|
409
|
+
const pendingMatches = Boolean(
|
|
410
|
+
pending && pending.executionGeneration === state.executionGeneration && sameWorkflowOwner(pending.owner, owner),
|
|
411
|
+
);
|
|
412
|
+
if (!waiterMatches && !pendingMatches) return;
|
|
413
|
+
// Top-level journal attempts carry the full run/node/attempt identity while
|
|
414
|
+
// lifecycle owners carry the short attempt token. Nested calls have no
|
|
415
|
+
// top-level attempt fact and are fenced by their pending/waiter owner.
|
|
416
|
+
const currentAttemptId = state.run.attemptIds[callNodeId];
|
|
417
|
+
if (currentAttemptId !== undefined && currentAttemptId !== `${owner.runId}/${callNodeId}/${owner.attemptId}`)
|
|
418
|
+
return;
|
|
419
|
+
|
|
420
|
+
if (updateCallTierIdentity(state, callNodeId, data.tier)) {
|
|
421
|
+
this.persistCallTierIdentity(state, callNodeId);
|
|
422
|
+
}
|
|
423
|
+
// Created/started lifecycle facts carry managed identity before the RPC
|
|
424
|
+
// reply is necessarily available. They are observation-only here; terminal
|
|
425
|
+
// ownership is handled below once a completion/failure fact arrives.
|
|
426
|
+
if (eventName !== "subagents:completed" && eventName !== "subagents:failed") return;
|
|
322
427
|
|
|
323
428
|
const lifecycleStatus =
|
|
324
429
|
eventName === "subagents:completed"
|
|
@@ -353,15 +458,7 @@ export class WorkflowEngine {
|
|
|
353
458
|
...(Array.isArray(data.history) ? { history: [...data.history] } : {}),
|
|
354
459
|
};
|
|
355
460
|
|
|
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;
|
|
461
|
+
if (waiterMatches && waiter) {
|
|
365
462
|
this.resolveAgentWaiter(state, agentId, waiter, delivery);
|
|
366
463
|
return;
|
|
367
464
|
}
|
|
@@ -369,19 +466,33 @@ export class WorkflowEngine {
|
|
|
369
466
|
// spawn-managed can emit a terminal lifecycle event synchronously before its
|
|
370
467
|
// reply continuation registers the agent-id waiter. Buffer only an owner
|
|
371
468
|
// 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
|
-
)
|
|
469
|
+
if (!pendingMatches || state.bufferedTerminals.has(spawnKey) || state.bufferedTerminals.size >= MAX_AGENTS_PER_RUN)
|
|
381
470
|
return;
|
|
382
471
|
state.bufferedTerminals.set(spawnKey, { ...delivery, owner, executionGeneration: state.executionGeneration });
|
|
383
472
|
}
|
|
384
473
|
|
|
474
|
+
/** Persist a host-normalized tier learned before the managed reply arrives. */
|
|
475
|
+
private persistCallTierIdentity(state: ScriptRunState, nodeId: string): void {
|
|
476
|
+
const attemptId = state.run.attemptIds[nodeId];
|
|
477
|
+
const identity = state.callTiers.get(nodeId);
|
|
478
|
+
// Nested workflow frames are in-memory only; only a top-level call has a
|
|
479
|
+
// durable attempt owner and can safely receive a journal transition.
|
|
480
|
+
if (!attemptId || identity?.tier === undefined) return;
|
|
481
|
+
this.persist(state.run.runId, `call-tier:${nodeId}`, () =>
|
|
482
|
+
this.write({
|
|
483
|
+
kind: "call_transition",
|
|
484
|
+
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
485
|
+
runId: state.run.runId,
|
|
486
|
+
nodeId,
|
|
487
|
+
status: "running",
|
|
488
|
+
tier: identity.tier,
|
|
489
|
+
attemptId,
|
|
490
|
+
owner: { extension: "pi-workflows", runId: state.run.runId, nodeId, attemptId },
|
|
491
|
+
timestamp: Date.now(),
|
|
492
|
+
}),
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
385
496
|
private resolveAgentWaiter(
|
|
386
497
|
state: ScriptRunState,
|
|
387
498
|
agentId: string,
|
|
@@ -397,6 +508,9 @@ export class WorkflowEngine {
|
|
|
397
508
|
}
|
|
398
509
|
|
|
399
510
|
dispose(): void {
|
|
511
|
+
if (this.disposed) return;
|
|
512
|
+
this.disposed = true;
|
|
513
|
+
this.disposeController.abort();
|
|
400
514
|
this.unsubscribeLifecycle();
|
|
401
515
|
this.quarantinedRunIds.clear();
|
|
402
516
|
for (const state of this.runs.values()) {
|
|
@@ -405,25 +519,25 @@ export class WorkflowEngine {
|
|
|
405
519
|
}
|
|
406
520
|
this.rejectWaiters(new WorkflowWaitAbortedError());
|
|
407
521
|
this.runs.clear();
|
|
408
|
-
|
|
409
|
-
this.providerResumeTimers.clear();
|
|
522
|
+
this.clearJournalRetries();
|
|
410
523
|
this.branchQuiescePromise = undefined;
|
|
411
524
|
}
|
|
412
525
|
|
|
413
526
|
suspendLifecycle(): void {
|
|
414
|
-
if (this.lifecyclePaused) return;
|
|
527
|
+
if (this.disposed || this.lifecyclePaused) return;
|
|
415
528
|
this.lifecyclePaused = true;
|
|
416
529
|
this.unsubscribeLifecycle();
|
|
417
530
|
}
|
|
418
531
|
|
|
419
532
|
resumeLifecycle(): void {
|
|
420
|
-
if (!this.lifecyclePaused) return;
|
|
533
|
+
if (this.disposed || !this.lifecyclePaused) return;
|
|
421
534
|
this.lifecyclePaused = false;
|
|
422
535
|
this.unsubscribeLifecycle = this.attachLifecycle();
|
|
423
536
|
}
|
|
424
537
|
|
|
425
538
|
/** Stop owned agents while Pi replaces the active session-tree branch. */
|
|
426
539
|
async quiesceForBranchChange(): Promise<WorkflowQuiesceResult> {
|
|
540
|
+
if (this.disposed) return { settled: true, pending: [] };
|
|
427
541
|
if (this.branchQuiescePromise) return this.branchQuiescePromise;
|
|
428
542
|
this.clearJournalRetries();
|
|
429
543
|
|
|
@@ -506,11 +620,15 @@ export class WorkflowEngine {
|
|
|
506
620
|
* the body it executes).
|
|
507
621
|
*/
|
|
508
622
|
async start(script: string, options: ScriptStartOptions = {}): Promise<ScriptStartResult> {
|
|
623
|
+
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
624
|
+
const protocol = await this.awaitProtocol();
|
|
625
|
+
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
509
626
|
const { meta } = parseWorkflowScript(script);
|
|
510
627
|
const now = Date.now();
|
|
511
628
|
const runId = randomUUID();
|
|
512
629
|
const scriptHash = hashScript(script);
|
|
513
630
|
const toolset = options.toolset;
|
|
631
|
+
const shippedScript = options.shippedScript === true ? true : undefined;
|
|
514
632
|
const durableArgs = cloneWorkflowArgs(options.args);
|
|
515
633
|
const runtimeArgs = cloneWorkflowArgs(durableArgs);
|
|
516
634
|
const journalArgs = cloneWorkflowArgs(durableArgs);
|
|
@@ -539,6 +657,7 @@ export class WorkflowEngine {
|
|
|
539
657
|
meta,
|
|
540
658
|
...(durableArgs === undefined ? {} : { args: durableArgs }),
|
|
541
659
|
frozenArgsPresent,
|
|
660
|
+
...(shippedScript === undefined ? {} : { shippedScript }),
|
|
542
661
|
...(toolset ? { toolset } : {}),
|
|
543
662
|
...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
|
|
544
663
|
...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
|
|
@@ -552,6 +671,7 @@ export class WorkflowEngine {
|
|
|
552
671
|
attempts: {},
|
|
553
672
|
attemptIds: {},
|
|
554
673
|
callResults: {},
|
|
674
|
+
callTiers: {},
|
|
555
675
|
workflowResultGenerations: {},
|
|
556
676
|
compactions: {},
|
|
557
677
|
startedAt: now,
|
|
@@ -566,6 +686,7 @@ export class WorkflowEngine {
|
|
|
566
686
|
meta,
|
|
567
687
|
...(journalArgs === undefined ? {} : { args: journalArgs }),
|
|
568
688
|
frozenArgsPresent,
|
|
689
|
+
...(shippedScript === undefined ? {} : { shippedScript }),
|
|
569
690
|
...(toolset ? { toolset } : {}),
|
|
570
691
|
...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
|
|
571
692
|
...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
|
|
@@ -584,10 +705,12 @@ export class WorkflowEngine {
|
|
|
584
705
|
...(frozenAgentRetries !== undefined ? { agentRetries: frozenAgentRetries } : {}),
|
|
585
706
|
...(frozenAgentTimeoutMs !== undefined ? { agentTimeoutMs: frozenAgentTimeoutMs } : {}),
|
|
586
707
|
...(options.toolset ? { toolset: options.toolset } : {}),
|
|
708
|
+
...(shippedScript === undefined ? {} : { shippedScript }),
|
|
587
709
|
...(options.excludeTools ? { excludeTools: [...options.excludeTools] } : {}),
|
|
588
710
|
},
|
|
589
711
|
controller: new AbortController(),
|
|
590
712
|
generations: new Map(),
|
|
713
|
+
callTiers: new Map(Object.entries(run.callTiers ?? {})),
|
|
591
714
|
agentWaiters: new Map(),
|
|
592
715
|
bufferedTerminals: new Map(),
|
|
593
716
|
pendingSpawns: new Map(),
|
|
@@ -598,7 +721,7 @@ export class WorkflowEngine {
|
|
|
598
721
|
this.runs.set(runId, state);
|
|
599
722
|
this.setWorkflowStatus(run, "running");
|
|
600
723
|
state.executionGeneration += 1;
|
|
601
|
-
state.execution = this.execute(runId, script, startOptions, undefined, state.executionGeneration);
|
|
724
|
+
state.execution = this.execute(runId, script, startOptions, undefined, state.executionGeneration, protocol);
|
|
602
725
|
// A run-fatal abort/stop already journals its terminal state; never let a
|
|
603
726
|
// leftover rejection become an unhandled promise rejection.
|
|
604
727
|
void state.execution.catch(() => {});
|
|
@@ -633,6 +756,9 @@ export class WorkflowEngine {
|
|
|
633
756
|
options: ScriptStartOptions = {},
|
|
634
757
|
replacementScript?: string,
|
|
635
758
|
): Promise<ScriptStartResult | undefined> {
|
|
759
|
+
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
760
|
+
const protocol = await this.awaitProtocol();
|
|
761
|
+
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
636
762
|
const state = this.runs.get(runId);
|
|
637
763
|
if (!state) return undefined;
|
|
638
764
|
if (state.lifecycleSuspended) return undefined;
|
|
@@ -664,26 +790,9 @@ export class WorkflowEngine {
|
|
|
664
790
|
}),
|
|
665
791
|
);
|
|
666
792
|
}
|
|
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
|
-
}
|
|
793
|
+
// Arguments are frozen in every schema-v4 run_created fact. Resume never accepts
|
|
794
|
+
// replacement args and never backfills the pre-schema-v4 run_args contract.
|
|
795
|
+
const resumeArgs = cloneWorkflowArgs(run.args);
|
|
687
796
|
const resumeJournal = buildResumeJournal(entries);
|
|
688
797
|
// Seed generation bases from journaled attemptIds so a live re-dispatch of a
|
|
689
798
|
// previously-journaled call never reuses a spawnKey (A4).
|
|
@@ -705,11 +814,19 @@ export class WorkflowEngine {
|
|
|
705
814
|
agentRetries: frozen.agentRetries,
|
|
706
815
|
agentTimeoutMs: frozen.agentTimeoutMs,
|
|
707
816
|
toolset: frozen.toolset,
|
|
817
|
+
shippedScript: frozen.shippedScript,
|
|
708
818
|
excludeTools: frozen.excludeTools ? [...frozen.excludeTools] : undefined,
|
|
709
819
|
};
|
|
710
820
|
state.bufferedTerminals.clear();
|
|
711
821
|
state.executionGeneration += 1;
|
|
712
|
-
state.execution = this.execute(
|
|
822
|
+
state.execution = this.execute(
|
|
823
|
+
runId,
|
|
824
|
+
run.script,
|
|
825
|
+
resumeOptions,
|
|
826
|
+
resumeJournal,
|
|
827
|
+
state.executionGeneration,
|
|
828
|
+
protocol,
|
|
829
|
+
);
|
|
713
830
|
void state.execution.catch(() => {});
|
|
714
831
|
if (options.background) return { runId, status: run.status, background: true };
|
|
715
832
|
try {
|
|
@@ -726,6 +843,10 @@ export class WorkflowEngine {
|
|
|
726
843
|
runId?: string,
|
|
727
844
|
entries?: readonly SessionEntryLike[],
|
|
728
845
|
): Promise<{ action: string; runs?: unknown[]; run?: unknown }> {
|
|
846
|
+
if (this.disposed) {
|
|
847
|
+
if (action === "list") return { action, runs: [] };
|
|
848
|
+
throw new WorkflowEngineDisposedError();
|
|
849
|
+
}
|
|
729
850
|
if (action === "list") return { action, runs: this.list() };
|
|
730
851
|
if (!runId) throw new Error(`${action} requires runId`);
|
|
731
852
|
const state = this.runs.get(runId);
|
|
@@ -855,7 +976,9 @@ export class WorkflowEngine {
|
|
|
855
976
|
options: ScriptStartOptions,
|
|
856
977
|
resumeJournal?: Map<string, JournalEntry>,
|
|
857
978
|
executionGeneration?: number,
|
|
979
|
+
protocol?: ManagedProtocolCheck,
|
|
858
980
|
): Promise<WorkflowRunResult<unknown>> {
|
|
981
|
+
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
859
982
|
const state = this.runs.get(runId);
|
|
860
983
|
if (!state) throw new Error(`workflow run not found: ${runId}`);
|
|
861
984
|
const generation = executionGeneration ?? state.executionGeneration;
|
|
@@ -871,6 +994,12 @@ export class WorkflowEngine {
|
|
|
871
994
|
args: options.args,
|
|
872
995
|
agent: runner,
|
|
873
996
|
mainModel: options.mainModel,
|
|
997
|
+
...(protocol === undefined
|
|
998
|
+
? {}
|
|
999
|
+
: {
|
|
1000
|
+
routingPolicy: protocol.routingPolicy,
|
|
1001
|
+
routingPolicyFingerprint: protocol.routingPolicyFingerprint,
|
|
1002
|
+
}),
|
|
874
1003
|
concurrency: options.concurrency,
|
|
875
1004
|
agentRetries: options.agentRetries,
|
|
876
1005
|
tokenBudget: options.tokenBudget,
|
|
@@ -881,6 +1010,7 @@ export class WorkflowEngine {
|
|
|
881
1010
|
executionNonce: `${generation}-${randomUUID()}`,
|
|
882
1011
|
resumeJournal,
|
|
883
1012
|
toolset: options.toolset,
|
|
1013
|
+
shippedScript: options.shippedScript,
|
|
884
1014
|
excludeTools: options.excludeTools,
|
|
885
1015
|
confirm: options.confirm,
|
|
886
1016
|
loadSavedWorkflow: options.loadSavedWorkflow,
|
|
@@ -947,6 +1077,7 @@ export class WorkflowEngine {
|
|
|
947
1077
|
* namespace so a changed child cannot reuse a stale managed identity.
|
|
948
1078
|
*/
|
|
949
1079
|
private async dispatchAgent(runId: string, prompt: string, runOptions: AgentRunOptions): Promise<unknown> {
|
|
1080
|
+
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
950
1081
|
const state = this.runs.get(runId);
|
|
951
1082
|
if (!state) throw new Error(`workflow run not found: ${runId}`);
|
|
952
1083
|
if (state.lifecycleSuspended || state.controller.signal.aborted) {
|
|
@@ -977,16 +1108,25 @@ export class WorkflowEngine {
|
|
|
977
1108
|
const attemptId = `${runId}/${nodeId}/attempt-${generation}`;
|
|
978
1109
|
|
|
979
1110
|
const type = runOptions.agentType ?? "general-purpose";
|
|
980
|
-
const tier =
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1111
|
+
const tier = runOptions.tier;
|
|
1112
|
+
if (tier !== undefined && !isManagedAgentTier(tier)) {
|
|
1113
|
+
throw new WorkflowError(
|
|
1114
|
+
`agent tier must be a non-empty, whitespace-free key (received ${String(tier)})`,
|
|
1115
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
1116
|
+
{ recoverable: false },
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
// This dispatch is authoritative for the current script revision. Do not
|
|
1120
|
+
// carry an identity from the previous script: deleting `tier` must clear
|
|
1121
|
+
// the recorded one instead of silently preserving the old route.
|
|
1122
|
+
const tierIdentity: CallTierIdentity = tier === undefined ? {} : { tier };
|
|
1123
|
+
state.callTiers.set(nodeId, tierIdentity);
|
|
1124
|
+
run.callTiers[nodeId] = tierIdentity;
|
|
984
1125
|
const task: DispatchTask = {
|
|
985
1126
|
subagent_type: type,
|
|
986
1127
|
prompt,
|
|
987
1128
|
description: runOptions.label ?? `workflow call ${callIndex}`,
|
|
988
1129
|
...(tier === undefined ? {} : { tier }),
|
|
989
|
-
...(runOptions.model === undefined ? {} : { model: runOptions.model }),
|
|
990
1130
|
...(runOptions.thread === undefined ? {} : { thread: runOptions.thread }),
|
|
991
1131
|
...(runOptions.toolset === undefined ? {} : { toolset: runOptions.toolset }),
|
|
992
1132
|
...(runOptions.excludeTools === undefined ? {} : { excludeTools: runOptions.excludeTools }),
|
|
@@ -1005,6 +1145,7 @@ export class WorkflowEngine {
|
|
|
1005
1145
|
nodeId,
|
|
1006
1146
|
attemptId,
|
|
1007
1147
|
generation,
|
|
1148
|
+
...(tierIdentity.tier === undefined ? {} : { tier: tierIdentity.tier }),
|
|
1008
1149
|
owner: { extension: "pi-workflows", runId, nodeId, attemptId },
|
|
1009
1150
|
timestamp: Date.now(),
|
|
1010
1151
|
}),
|
|
@@ -1039,7 +1180,7 @@ export class WorkflowEngine {
|
|
|
1039
1180
|
let pendingId = "";
|
|
1040
1181
|
let response: ManagedSpawnResponse | string;
|
|
1041
1182
|
try {
|
|
1042
|
-
response = await this.client.spawn(task, runId, spawnNodeId, spawnAttemptId,
|
|
1183
|
+
response = await this.client.spawn(task, runId, spawnNodeId, spawnAttemptId, externalSignal);
|
|
1043
1184
|
} finally {
|
|
1044
1185
|
externalSignal.removeEventListener("abort", onPendingAbort);
|
|
1045
1186
|
state.pendingSpawns.delete(spawnKey);
|
|
@@ -1060,6 +1201,14 @@ export class WorkflowEngine {
|
|
|
1060
1201
|
{ recoverable: true },
|
|
1061
1202
|
);
|
|
1062
1203
|
}
|
|
1204
|
+
if (typeof response !== "string" && updateCallTierIdentity(state, nodeId, response.tier)) {
|
|
1205
|
+
this.persistCallTierIdentity(state, nodeId);
|
|
1206
|
+
}
|
|
1207
|
+
// Report the tier the host actually selected back to the runtime, which
|
|
1208
|
+
// journals it: a call that named none still resolved to something, and the
|
|
1209
|
+
// run's own record of what ran should say what.
|
|
1210
|
+
const resolvedTierIdentity = state.callTiers.get(nodeId);
|
|
1211
|
+
if (resolvedTierIdentity?.tier !== undefined) runOptions.onTierResolved?.(resolvedTierIdentity.tier);
|
|
1063
1212
|
if (typeof response === "string") {
|
|
1064
1213
|
pendingId = response;
|
|
1065
1214
|
} else if (response.terminal) {
|
|
@@ -1087,6 +1236,7 @@ export class WorkflowEngine {
|
|
|
1087
1236
|
terminal.error,
|
|
1088
1237
|
terminal.compactionCount,
|
|
1089
1238
|
terminal.tokenCount,
|
|
1239
|
+
resolvedTierIdentity?.tier ?? tier,
|
|
1090
1240
|
);
|
|
1091
1241
|
result.attemptId = attemptId;
|
|
1092
1242
|
if (!nested) this.commitCallTerminal(runId, nodeId, result);
|
|
@@ -1171,6 +1321,7 @@ export class WorkflowEngine {
|
|
|
1171
1321
|
snapshot.error,
|
|
1172
1322
|
snapshot.compactionCount,
|
|
1173
1323
|
snapshot.tokenCount,
|
|
1324
|
+
state.callTiers.get(nodeId)?.tier ?? tier,
|
|
1174
1325
|
);
|
|
1175
1326
|
result.attemptId = attemptId;
|
|
1176
1327
|
if (!nested) this.commitCallTerminal(runId, nodeId, result);
|
|
@@ -1178,6 +1329,8 @@ export class WorkflowEngine {
|
|
|
1178
1329
|
throw wrapError(new Error(snapshot.error), { agentLabel: runOptions.label });
|
|
1179
1330
|
}
|
|
1180
1331
|
}
|
|
1332
|
+
const completedTierIdentity = state.callTiers.get(nodeId);
|
|
1333
|
+
if (completedTierIdentity?.tier !== undefined) runOptions.onTierResolved?.(completedTierIdentity.tier);
|
|
1181
1334
|
return snapshot.result ?? "";
|
|
1182
1335
|
}
|
|
1183
1336
|
|
|
@@ -1187,9 +1340,12 @@ export class WorkflowEngine {
|
|
|
1187
1340
|
const run = state.run;
|
|
1188
1341
|
const nodeId = String(entry.index);
|
|
1189
1342
|
const result = entry.result;
|
|
1343
|
+
const callTierIdentity: CallTierIdentity =
|
|
1344
|
+
entry.tier === undefined ? (run.callTiers[nodeId] ?? {}) : { tier: entry.tier };
|
|
1190
1345
|
const callResult: CallResult = {
|
|
1191
1346
|
status: "completed",
|
|
1192
1347
|
...(typeof result === "string" && result.length === 0 ? {} : { result }),
|
|
1348
|
+
...callTierIdentity,
|
|
1193
1349
|
compactionCount: 0,
|
|
1194
1350
|
updatedAt: Date.now(),
|
|
1195
1351
|
};
|
|
@@ -1211,6 +1367,7 @@ export class WorkflowEngine {
|
|
|
1211
1367
|
};
|
|
1212
1368
|
this.persist(runId, `call-result:${nodeId}`, () => this.write(event));
|
|
1213
1369
|
run.callResults[nodeId] = callResult;
|
|
1370
|
+
run.callTiers[nodeId] = callResult.tier === undefined ? {} : { tier: callResult.tier };
|
|
1214
1371
|
run.callStatus[nodeId] = "completed";
|
|
1215
1372
|
run.updatedAt = Date.now();
|
|
1216
1373
|
}
|
|
@@ -1222,6 +1379,7 @@ export class WorkflowEngine {
|
|
|
1222
1379
|
const result: CallResult = {
|
|
1223
1380
|
status: "completed",
|
|
1224
1381
|
...(entry.result === undefined ? {} : { result: entry.result }),
|
|
1382
|
+
...(entry.tier === undefined ? {} : { tier: entry.tier }),
|
|
1225
1383
|
compactionCount: 0,
|
|
1226
1384
|
updatedAt: Date.now(),
|
|
1227
1385
|
};
|
|
@@ -1245,6 +1403,7 @@ export class WorkflowEngine {
|
|
|
1245
1403
|
this.appendRequired(event);
|
|
1246
1404
|
state.run.workflowResultGenerations[nodeId] = event.generation;
|
|
1247
1405
|
state.run.callResults[nodeId] = result;
|
|
1406
|
+
state.run.callTiers[nodeId] = result.tier === undefined ? {} : { tier: result.tier };
|
|
1248
1407
|
state.run.callStatus[nodeId] = "completed";
|
|
1249
1408
|
state.run.updatedAt = Date.now();
|
|
1250
1409
|
}
|
|
@@ -1266,6 +1425,7 @@ export class WorkflowEngine {
|
|
|
1266
1425
|
};
|
|
1267
1426
|
this.persist(runId, `call-terminal:${nodeId}`, () => this.write(event));
|
|
1268
1427
|
run.callResults[nodeId] = result;
|
|
1428
|
+
run.callTiers[nodeId] = result.tier === undefined ? {} : { tier: result.tier };
|
|
1269
1429
|
run.callStatus[nodeId] = result.status;
|
|
1270
1430
|
if (result.agentId) run.agentIds[nodeId] = result.agentId;
|
|
1271
1431
|
run.compactions[nodeId] = Math.max(run.compactions[nodeId] ?? 0, result.compactionCount);
|
|
@@ -1343,6 +1503,8 @@ export class WorkflowEngine {
|
|
|
1343
1503
|
undefined,
|
|
1344
1504
|
"workflow stopped",
|
|
1345
1505
|
run.compactions[nodeId] ?? 0,
|
|
1506
|
+
undefined,
|
|
1507
|
+
state.callTiers.get(nodeId)?.tier,
|
|
1346
1508
|
);
|
|
1347
1509
|
result.attemptId = run.attemptIds[nodeId];
|
|
1348
1510
|
terminalResults.push({
|
|
@@ -1497,11 +1659,19 @@ export class WorkflowEngine {
|
|
|
1497
1659
|
this.providerResumeTimers.delete(runId);
|
|
1498
1660
|
const state = this.runs.get(runId);
|
|
1499
1661
|
if (state?.run.status !== "paused") return;
|
|
1500
|
-
void
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1662
|
+
void Promise.resolve()
|
|
1663
|
+
.then(() => {
|
|
1664
|
+
const current = this.runs.get(runId);
|
|
1665
|
+
if (current?.run.status !== "paused") return;
|
|
1666
|
+
// resume() is the single authorization gate and captures the fresh
|
|
1667
|
+
// routing-policy fingerprint for this execution.
|
|
1668
|
+
return this.resume(runId, this.readEntries(), { background: true });
|
|
1669
|
+
})
|
|
1670
|
+
.catch((error: unknown) => {
|
|
1671
|
+
console.warn(
|
|
1672
|
+
`[pi-workflows] automatic provider-limit resume failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1673
|
+
);
|
|
1674
|
+
});
|
|
1505
1675
|
}, delay);
|
|
1506
1676
|
timer.unref?.();
|
|
1507
1677
|
this.providerResumeTimers.set(runId, timer);
|
|
@@ -1556,6 +1726,7 @@ export class WorkflowEngine {
|
|
|
1556
1726
|
// ── restore / summary ──────────────────────────────────────────────────────
|
|
1557
1727
|
|
|
1558
1728
|
restore(entries: readonly SessionEntryLike[], branchGeneration = 0): void {
|
|
1729
|
+
if (this.disposed) return;
|
|
1559
1730
|
// A branch replay is a replacement, not a merge. Runs absent from the new
|
|
1560
1731
|
// branch must not remain controllable or append facts into it.
|
|
1561
1732
|
this.clearJournalRetries();
|
|
@@ -1579,10 +1750,12 @@ export class WorkflowEngine {
|
|
|
1579
1750
|
...(run.frozenAgentRetries !== undefined ? { agentRetries: run.frozenAgentRetries } : {}),
|
|
1580
1751
|
...(run.frozenAgentTimeoutMs !== undefined ? { agentTimeoutMs: run.frozenAgentTimeoutMs } : {}),
|
|
1581
1752
|
...(run.toolset ? { toolset: run.toolset } : {}),
|
|
1753
|
+
...(run.shippedScript === undefined ? {} : { shippedScript: run.shippedScript }),
|
|
1582
1754
|
...(run.frozenExcludeTools ? { excludeTools: [...run.frozenExcludeTools] } : {}),
|
|
1583
1755
|
},
|
|
1584
1756
|
controller: new AbortController(),
|
|
1585
1757
|
generations: new Map(),
|
|
1758
|
+
callTiers: new Map(Object.entries(run.callTiers ?? {})),
|
|
1586
1759
|
agentWaiters: new Map(),
|
|
1587
1760
|
bufferedTerminals: new Map(),
|
|
1588
1761
|
pendingSpawns: new Map(),
|
|
@@ -1658,6 +1831,13 @@ function hashScript(script: string): string {
|
|
|
1658
1831
|
return createHash("sha256").update(script).digest("hex");
|
|
1659
1832
|
}
|
|
1660
1833
|
|
|
1834
|
+
export class WorkflowEngineDisposedError extends Error {
|
|
1835
|
+
constructor() {
|
|
1836
|
+
super("Workflow engine is disposed");
|
|
1837
|
+
this.name = "WorkflowEngineDisposedError";
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1661
1841
|
export class WorkflowWaitAbortedError extends Error {
|
|
1662
1842
|
constructor() {
|
|
1663
1843
|
super("workflow wait aborted");
|