@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.
- package/CHANGELOG.md +140 -0
- package/README.md +93 -22
- package/package.json +5 -4
- package/skills/workflow-authoring/SKILL.md +171 -18
- package/skills/workflow-authoring/examples/named-task-graph.js +34 -0
- package/skills/workflow-authoring/references/capabilities.md +4 -3
- package/skills/workflow-authoring/references/capability-details.md +28 -8
- package/skills/workflow-authoring/references/debugging.md +2 -2
- package/skills/workflow-authoring/references/focused-recipes.md +1 -0
- package/skills/workflow-authoring/references/lifecycle.md +3 -3
- package/skills/workflow-authoring/references/pattern-selection.md +1 -0
- package/skills/workflow-authoring/references/registry-ownership.md +6 -4
- package/skills/workflow-authoring/references/review.md +12 -7
- package/skills/workflow-authoring/references/runtime.md +6 -5
- package/skills/workflow-authoring/references/versions.md +1 -1
- package/skills/workflow-patterns/SKILL.md +72 -33
- package/skills/workflow-review/SKILL.md +70 -0
- package/src/builtins.ts +23 -23
- package/src/capabilities.ts +39 -7
- package/src/engine.ts +582 -142
- package/src/index.ts +413 -154
- package/src/journal.ts +447 -169
- package/src/orchestrator.ts +478 -0
- package/src/rpc-client.ts +37 -48
- package/src/runtime.ts +508 -167
- 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";
|
|
@@ -46,10 +49,17 @@ import {
|
|
|
46
49
|
type AgentRunOptions,
|
|
47
50
|
type AgentUsage,
|
|
48
51
|
type JournalEntry,
|
|
52
|
+
MAX_AGENTS_PER_RUN,
|
|
53
|
+
normalizeAgentRetries,
|
|
54
|
+
normalizeAgentTimeout,
|
|
55
|
+
normalizeConcurrency,
|
|
56
|
+
normalizeMaxAgents,
|
|
57
|
+
normalizeTokenBudget,
|
|
49
58
|
parseWorkflowScript,
|
|
50
59
|
runWorkflow,
|
|
51
60
|
type WorkflowAgentRunner,
|
|
52
61
|
type WorkflowRunResult,
|
|
62
|
+
type WorkflowRuntimeEvent,
|
|
53
63
|
} from "./runtime.js";
|
|
54
64
|
|
|
55
65
|
export const MAX_ATTEMPTS_PER_NODE = 3;
|
|
@@ -78,6 +88,8 @@ export interface ScriptStartOptions {
|
|
|
78
88
|
concurrency?: number;
|
|
79
89
|
agentRetries?: number;
|
|
80
90
|
tokenBudget?: number | null;
|
|
91
|
+
/** True for a script this package ships; see WorkflowRunOptions.shippedScript. */
|
|
92
|
+
shippedScript?: boolean;
|
|
81
93
|
agentTimeoutMs?: number | null;
|
|
82
94
|
toolset?: string;
|
|
83
95
|
excludeTools?: string[];
|
|
@@ -93,7 +105,13 @@ export interface ScriptStartOptions {
|
|
|
93
105
|
timeoutMs?: number;
|
|
94
106
|
},
|
|
95
107
|
) => Promise<unknown>;
|
|
96
|
-
|
|
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;
|
|
97
115
|
mainModel?: string;
|
|
98
116
|
}
|
|
99
117
|
|
|
@@ -106,12 +124,41 @@ export interface ScriptStartResult {
|
|
|
106
124
|
waitAborted?: boolean;
|
|
107
125
|
}
|
|
108
126
|
|
|
127
|
+
interface AgentLifecycleWaiter {
|
|
128
|
+
callIndex: string;
|
|
129
|
+
generation: number;
|
|
130
|
+
onUsage?: (usage: AgentUsage) => void;
|
|
131
|
+
onHistory?: (history: unknown[]) => void;
|
|
132
|
+
executionGeneration: number;
|
|
133
|
+
resolve: (response: ManagedSpawnResponse) => void;
|
|
134
|
+
reject: (error: unknown) => void;
|
|
135
|
+
settled: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface AgentTerminalDelivery {
|
|
139
|
+
response: ManagedSpawnResponse;
|
|
140
|
+
usage?: AgentUsage;
|
|
141
|
+
history?: unknown[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface BufferedAgentTerminal extends AgentTerminalDelivery {
|
|
145
|
+
owner: WorkflowOwner;
|
|
146
|
+
executionGeneration: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
109
149
|
export interface ScriptRunState {
|
|
110
150
|
run: ScriptRun;
|
|
111
151
|
/** Frozen run parameters — resume reuses the original values, ignoring new budget/toolset overrides. */
|
|
112
152
|
frozenOptions: Pick<
|
|
113
153
|
ScriptStartOptions,
|
|
114
|
-
|
|
154
|
+
| "tokenBudget"
|
|
155
|
+
| "maxAgents"
|
|
156
|
+
| "concurrency"
|
|
157
|
+
| "agentRetries"
|
|
158
|
+
| "agentTimeoutMs"
|
|
159
|
+
| "toolset"
|
|
160
|
+
| "shippedScript"
|
|
161
|
+
| "excludeTools"
|
|
115
162
|
>;
|
|
116
163
|
/** Abort controller for this run's script execution. */
|
|
117
164
|
controller: AbortController;
|
|
@@ -123,22 +170,14 @@ export interface ScriptRunState {
|
|
|
123
170
|
result?: unknown;
|
|
124
171
|
/** Per-call-index generation base for A4 spawnKey rotation (seeded from journal). */
|
|
125
172
|
generations: Map<string, number>;
|
|
173
|
+
/** Resolved tier identity for live calls, including stopped terminal results. */
|
|
174
|
+
callTiers: Map<string, CallTierIdentity>;
|
|
126
175
|
/** Live dispatch waiters keyed by agent id. */
|
|
127
|
-
agentWaiters: Map<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
callIndex: string;
|
|
131
|
-
generation: number;
|
|
132
|
-
onUsage?: (usage: AgentUsage) => void;
|
|
133
|
-
onHistory?: (history: unknown[]) => void;
|
|
134
|
-
executionGeneration: number;
|
|
135
|
-
resolve: (r: ManagedSpawnResponse) => void;
|
|
136
|
-
reject: (e: unknown) => void;
|
|
137
|
-
settled: boolean;
|
|
138
|
-
}
|
|
139
|
-
>;
|
|
176
|
+
agentWaiters: Map<string, AgentLifecycleWaiter>;
|
|
177
|
+
/** Owner-validated terminal events emitted before their spawn reply/waiter. */
|
|
178
|
+
bufferedTerminals: Map<string, BufferedAgentTerminal>;
|
|
140
179
|
/** Allocations whose managed-spawn reply has not arrived yet. */
|
|
141
|
-
pendingSpawns: Map<string, { spawnKey: string; owner: WorkflowOwner }>;
|
|
180
|
+
pendingSpawns: Map<string, { spawnKey: string; owner: WorkflowOwner; executionGeneration: number }>;
|
|
142
181
|
/** Journal retry bookkeeping. */
|
|
143
182
|
pendingJournal: Set<string>;
|
|
144
183
|
/** Whether a lifecycle suspension is in effect (branch change). */
|
|
@@ -153,6 +192,60 @@ function managedTerminalStatus(snapshot: { status: string }): "completed" | "fai
|
|
|
153
192
|
return snapshot.status === "completed" ? "completed" : snapshot.status === "failed" ? "failed" : "stopped";
|
|
154
193
|
}
|
|
155
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
|
+
|
|
208
|
+
function renderWorkflowValue(value: unknown): string {
|
|
209
|
+
if (typeof value === "string") return value;
|
|
210
|
+
try {
|
|
211
|
+
const serialized = JSON.stringify(value, null, 2);
|
|
212
|
+
return serialized === undefined ? String(value) : serialized;
|
|
213
|
+
} catch {
|
|
214
|
+
return String(value);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function cloneWorkflowValue(value: unknown, label: string): unknown {
|
|
219
|
+
if (value === undefined) return undefined;
|
|
220
|
+
try {
|
|
221
|
+
const cloned = structuredClone(value);
|
|
222
|
+
const serialized = JSON.stringify(cloned);
|
|
223
|
+
if (serialized === undefined) throw new Error("value is not JSON-serializable");
|
|
224
|
+
if (serialized.length > 200_000) {
|
|
225
|
+
throw new Error("value exceeds the 200000-character workflow persistence limit");
|
|
226
|
+
}
|
|
227
|
+
return cloned;
|
|
228
|
+
} catch (error: unknown) {
|
|
229
|
+
throw new WorkflowError(
|
|
230
|
+
`${label} must be structured-cloneable and JSON-serializable: ${error instanceof Error ? error.message : String(error)}`,
|
|
231
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
232
|
+
{ recoverable: false, details: error },
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function cloneWorkflowArgs(value: unknown): unknown {
|
|
238
|
+
return cloneWorkflowValue(value, "Workflow args");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function cloneWorkflowResult(value: unknown): unknown {
|
|
242
|
+
return cloneWorkflowValue(value, "Workflow result");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function cloneRuntimeEvent(event: WorkflowRuntimeEvent): WorkflowRuntimeEvent {
|
|
246
|
+
return structuredClone(event);
|
|
247
|
+
}
|
|
248
|
+
|
|
156
249
|
function resultFromLifecycle(
|
|
157
250
|
status: "completed" | "failed" | "stopped",
|
|
158
251
|
agentId: string | undefined,
|
|
@@ -160,10 +253,12 @@ function resultFromLifecycle(
|
|
|
160
253
|
error: string | undefined,
|
|
161
254
|
compactionCount: number,
|
|
162
255
|
tokenCount?: number,
|
|
256
|
+
tier?: string,
|
|
163
257
|
): CallResult {
|
|
164
258
|
return {
|
|
165
259
|
status,
|
|
166
260
|
...(agentId ? { agentId } : {}),
|
|
261
|
+
...(tier === undefined ? {} : { tier }),
|
|
167
262
|
...(result !== undefined && result.length > 0 ? { result } : {}),
|
|
168
263
|
...(error !== undefined && error.length > 0 ? { error } : {}),
|
|
169
264
|
...(tokenCount !== undefined ? { tokenCount } : {}),
|
|
@@ -179,15 +274,29 @@ function generationFromAttemptId(attemptId: string | undefined): number {
|
|
|
179
274
|
return match ? Number(match[1]) : 0;
|
|
180
275
|
}
|
|
181
276
|
|
|
277
|
+
function sameWorkflowOwner(left: WorkflowOwner, right: WorkflowOwner): boolean {
|
|
278
|
+
return (
|
|
279
|
+
left.extension === right.extension &&
|
|
280
|
+
left.runId === right.runId &&
|
|
281
|
+
left.nodeId === right.nodeId &&
|
|
282
|
+
left.attemptId === right.attemptId
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
182
286
|
export class WorkflowEngine {
|
|
183
287
|
private readonly runs = new Map<string, ScriptRunState>();
|
|
184
288
|
/** Fired when a run settles terminal; wired by the UI surface for widget refresh. */
|
|
185
289
|
onRunSettled: (() => void) | undefined;
|
|
290
|
+
/** Optional in-process observer for phase/task/quality runtime events. */
|
|
291
|
+
onRuntimeEvent: ((runId: string, event: WorkflowRuntimeEvent) => void) | undefined;
|
|
186
292
|
private readonly waiters = new Map<
|
|
187
293
|
string,
|
|
188
294
|
Set<{ resolve: (run: ScriptRun) => void; reject: (e: unknown) => void; settled: boolean; cleanup: () => void }>
|
|
189
295
|
>();
|
|
190
296
|
private unsubscribeLifecycle: () => void;
|
|
297
|
+
private readonly protocolGate: () => Promise<ManagedProtocolCheck>;
|
|
298
|
+
private readonly disposeController = new AbortController();
|
|
299
|
+
private disposed = false;
|
|
191
300
|
private lifecyclePaused = false;
|
|
192
301
|
private branchQuiescePromise: Promise<WorkflowQuiesceResult> | undefined;
|
|
193
302
|
private recoveryBranchGeneration = 0;
|
|
@@ -202,10 +311,37 @@ export class WorkflowEngine {
|
|
|
202
311
|
private readonly client: ManagedSpawnClient,
|
|
203
312
|
private readonly journal: JournalWriter,
|
|
204
313
|
private readonly readEntries: () => readonly SessionEntryLike[] = () => [],
|
|
314
|
+
protocolGate?: () => Promise<ManagedProtocolCheck>,
|
|
205
315
|
) {
|
|
316
|
+
this.protocolGate =
|
|
317
|
+
protocolGate ??
|
|
318
|
+
(() => this.client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable")));
|
|
206
319
|
this.unsubscribeLifecycle = this.attachLifecycle();
|
|
207
320
|
}
|
|
208
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
|
+
|
|
209
345
|
private attachLifecycle(): () => void {
|
|
210
346
|
const unsubs = [
|
|
211
347
|
"subagents:created",
|
|
@@ -224,63 +360,157 @@ export class WorkflowEngine {
|
|
|
224
360
|
}
|
|
225
361
|
|
|
226
362
|
private onLifecycle(eventName: string, raw: unknown): void {
|
|
363
|
+
if (
|
|
364
|
+
eventName !== "subagents:created" &&
|
|
365
|
+
eventName !== "subagents:started" &&
|
|
366
|
+
eventName !== "subagents:completed" &&
|
|
367
|
+
eventName !== "subagents:failed"
|
|
368
|
+
)
|
|
369
|
+
return;
|
|
227
370
|
if (!raw || typeof raw !== "object") return;
|
|
228
371
|
const data = raw as Record<string, unknown>;
|
|
229
372
|
const agentId = typeof data.id === "string" ? data.id : typeof data.agentId === "string" ? data.agentId : undefined;
|
|
230
373
|
if (!agentId) return;
|
|
231
374
|
|
|
232
|
-
|
|
233
|
-
if (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
waiter.settled
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
375
|
+
const rawOwner = data.owner;
|
|
376
|
+
if (!rawOwner || typeof rawOwner !== "object" || Array.isArray(rawOwner)) return;
|
|
377
|
+
const ownerRecord = rawOwner as Record<string, unknown>;
|
|
378
|
+
if (
|
|
379
|
+
ownerRecord.extension !== "pi-workflows" ||
|
|
380
|
+
typeof ownerRecord.runId !== "string" ||
|
|
381
|
+
typeof ownerRecord.nodeId !== "string" ||
|
|
382
|
+
typeof ownerRecord.attemptId !== "string"
|
|
383
|
+
)
|
|
384
|
+
return;
|
|
385
|
+
const owner: WorkflowOwner = {
|
|
386
|
+
extension: "pi-workflows",
|
|
387
|
+
runId: ownerRecord.runId,
|
|
388
|
+
nodeId: ownerRecord.nodeId,
|
|
389
|
+
attemptId: ownerRecord.attemptId,
|
|
390
|
+
};
|
|
391
|
+
const state = this.runs.get(owner.runId);
|
|
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);
|
|
280
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;
|
|
427
|
+
|
|
428
|
+
const lifecycleStatus =
|
|
429
|
+
eventName === "subagents:completed"
|
|
430
|
+
? "completed"
|
|
431
|
+
: data.status === "stopped" || data.status === "interrupted"
|
|
432
|
+
? "stopped"
|
|
433
|
+
: "failed";
|
|
434
|
+
const tokenCount =
|
|
435
|
+
typeof data.tokenCount === "number"
|
|
436
|
+
? data.tokenCount
|
|
437
|
+
: data.tokens &&
|
|
438
|
+
typeof data.tokens === "object" &&
|
|
439
|
+
typeof (data.tokens as Record<string, unknown>).total === "number"
|
|
440
|
+
? ((data.tokens as Record<string, unknown>).total as number)
|
|
441
|
+
: undefined;
|
|
442
|
+
const delivery: AgentTerminalDelivery = {
|
|
443
|
+
response: {
|
|
444
|
+
id: agentId,
|
|
445
|
+
terminal: {
|
|
446
|
+
status: lifecycleStatus,
|
|
447
|
+
...(typeof data.result === "string" ? { result: data.result } : {}),
|
|
448
|
+
...(typeof data.error === "string" ? { error: data.error } : {}),
|
|
449
|
+
...(typeof data.outputFile === "string" ? { outputFile: data.outputFile } : {}),
|
|
450
|
+
...(tokenCount === undefined ? {} : { tokenCount }),
|
|
451
|
+
compactionCount: typeof data.compactionCount === "number" ? data.compactionCount : 0,
|
|
452
|
+
completedAt: typeof data.completedAt === "number" ? data.completedAt : Date.now(),
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
...(tokenCount === undefined
|
|
456
|
+
? {}
|
|
457
|
+
: { usage: { input: 0, output: tokenCount, total: tokenCount, cost: 0, cacheRead: 0, cacheWrite: 0 } }),
|
|
458
|
+
...(Array.isArray(data.history) ? { history: [...data.history] } : {}),
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
if (waiterMatches && waiter) {
|
|
462
|
+
this.resolveAgentWaiter(state, agentId, waiter, delivery);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// spawn-managed can emit a terminal lifecycle event synchronously before its
|
|
467
|
+
// reply continuation registers the agent-id waiter. Buffer only an owner
|
|
468
|
+
// that is currently awaiting that exact reply in this execution generation.
|
|
469
|
+
if (!pendingMatches || state.bufferedTerminals.has(spawnKey) || state.bufferedTerminals.size >= MAX_AGENTS_PER_RUN)
|
|
470
|
+
return;
|
|
471
|
+
state.bufferedTerminals.set(spawnKey, { ...delivery, owner, executionGeneration: state.executionGeneration });
|
|
472
|
+
}
|
|
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
|
+
|
|
496
|
+
private resolveAgentWaiter(
|
|
497
|
+
state: ScriptRunState,
|
|
498
|
+
agentId: string,
|
|
499
|
+
waiter: AgentLifecycleWaiter,
|
|
500
|
+
delivery: AgentTerminalDelivery,
|
|
501
|
+
): void {
|
|
502
|
+
if (waiter.settled) return;
|
|
503
|
+
waiter.settled = true;
|
|
504
|
+
state.agentWaiters.delete(agentId);
|
|
505
|
+
if (delivery.usage) waiter.onUsage?.(delivery.usage);
|
|
506
|
+
if (delivery.history) waiter.onHistory?.(delivery.history);
|
|
507
|
+
waiter.resolve(delivery.response);
|
|
281
508
|
}
|
|
282
509
|
|
|
283
510
|
dispose(): void {
|
|
511
|
+
if (this.disposed) return;
|
|
512
|
+
this.disposed = true;
|
|
513
|
+
this.disposeController.abort();
|
|
284
514
|
this.unsubscribeLifecycle();
|
|
285
515
|
this.quarantinedRunIds.clear();
|
|
286
516
|
for (const state of this.runs.values()) {
|
|
@@ -289,25 +519,25 @@ export class WorkflowEngine {
|
|
|
289
519
|
}
|
|
290
520
|
this.rejectWaiters(new WorkflowWaitAbortedError());
|
|
291
521
|
this.runs.clear();
|
|
292
|
-
|
|
293
|
-
this.providerResumeTimers.clear();
|
|
522
|
+
this.clearJournalRetries();
|
|
294
523
|
this.branchQuiescePromise = undefined;
|
|
295
524
|
}
|
|
296
525
|
|
|
297
526
|
suspendLifecycle(): void {
|
|
298
|
-
if (this.lifecyclePaused) return;
|
|
527
|
+
if (this.disposed || this.lifecyclePaused) return;
|
|
299
528
|
this.lifecyclePaused = true;
|
|
300
529
|
this.unsubscribeLifecycle();
|
|
301
530
|
}
|
|
302
531
|
|
|
303
532
|
resumeLifecycle(): void {
|
|
304
|
-
if (!this.lifecyclePaused) return;
|
|
533
|
+
if (this.disposed || !this.lifecyclePaused) return;
|
|
305
534
|
this.lifecyclePaused = false;
|
|
306
535
|
this.unsubscribeLifecycle = this.attachLifecycle();
|
|
307
536
|
}
|
|
308
537
|
|
|
309
538
|
/** Stop owned agents while Pi replaces the active session-tree branch. */
|
|
310
539
|
async quiesceForBranchChange(): Promise<WorkflowQuiesceResult> {
|
|
540
|
+
if (this.disposed) return { settled: true, pending: [] };
|
|
311
541
|
if (this.branchQuiescePromise) return this.branchQuiescePromise;
|
|
312
542
|
this.clearJournalRetries();
|
|
313
543
|
|
|
@@ -329,6 +559,7 @@ export class WorkflowEngine {
|
|
|
329
559
|
});
|
|
330
560
|
targets.set(state.run.runId, { agentIds, owners });
|
|
331
561
|
state.lifecycleSuspended = true;
|
|
562
|
+
state.bufferedTerminals.clear();
|
|
332
563
|
state.controller.abort(new DOMException("Workflow branch change", "AbortError"));
|
|
333
564
|
}
|
|
334
565
|
|
|
@@ -389,23 +620,50 @@ export class WorkflowEngine {
|
|
|
389
620
|
* the body it executes).
|
|
390
621
|
*/
|
|
391
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();
|
|
392
626
|
const { meta } = parseWorkflowScript(script);
|
|
393
627
|
const now = Date.now();
|
|
394
628
|
const runId = randomUUID();
|
|
395
629
|
const scriptHash = hashScript(script);
|
|
396
630
|
const toolset = options.toolset;
|
|
631
|
+
const shippedScript = options.shippedScript === true ? true : undefined;
|
|
632
|
+
const durableArgs = cloneWorkflowArgs(options.args);
|
|
633
|
+
const runtimeArgs = cloneWorkflowArgs(durableArgs);
|
|
634
|
+
const journalArgs = cloneWorkflowArgs(durableArgs);
|
|
635
|
+
const frozenArgsPresent = durableArgs !== undefined;
|
|
636
|
+
const frozenMaxAgents = options.maxAgents === undefined ? undefined : normalizeMaxAgents(options.maxAgents);
|
|
637
|
+
const frozenConcurrency = options.concurrency === undefined ? undefined : normalizeConcurrency(options.concurrency);
|
|
638
|
+
const frozenAgentRetries =
|
|
639
|
+
options.agentRetries === undefined ? undefined : normalizeAgentRetries(options.agentRetries);
|
|
640
|
+
const frozenTokenBudget = options.tokenBudget === undefined ? undefined : normalizeTokenBudget(options.tokenBudget);
|
|
641
|
+
const frozenAgentTimeoutMs =
|
|
642
|
+
options.agentTimeoutMs === undefined ? undefined : normalizeAgentTimeout(options.agentTimeoutMs);
|
|
643
|
+
const startOptions: ScriptStartOptions = {
|
|
644
|
+
...options,
|
|
645
|
+
...(runtimeArgs === undefined ? {} : { args: runtimeArgs }),
|
|
646
|
+
...(frozenMaxAgents === undefined ? {} : { maxAgents: frozenMaxAgents }),
|
|
647
|
+
...(frozenConcurrency === undefined ? {} : { concurrency: frozenConcurrency }),
|
|
648
|
+
...(frozenAgentRetries === undefined ? {} : { agentRetries: frozenAgentRetries }),
|
|
649
|
+
...(frozenTokenBudget === undefined ? {} : { tokenBudget: frozenTokenBudget }),
|
|
650
|
+
...(frozenAgentTimeoutMs === undefined ? {} : { agentTimeoutMs: frozenAgentTimeoutMs }),
|
|
651
|
+
};
|
|
397
652
|
const run: ScriptRun = {
|
|
398
653
|
runId,
|
|
399
654
|
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
400
655
|
script,
|
|
401
656
|
scriptHash,
|
|
402
657
|
meta,
|
|
658
|
+
...(durableArgs === undefined ? {} : { args: durableArgs }),
|
|
659
|
+
frozenArgsPresent,
|
|
660
|
+
...(shippedScript === undefined ? {} : { shippedScript }),
|
|
403
661
|
...(toolset ? { toolset } : {}),
|
|
404
|
-
...(
|
|
405
|
-
...(
|
|
406
|
-
...(
|
|
407
|
-
...(
|
|
408
|
-
...(
|
|
662
|
+
...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
|
|
663
|
+
...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
|
|
664
|
+
...(frozenAgentRetries !== undefined ? { frozenAgentRetries } : {}),
|
|
665
|
+
...(frozenTokenBudget !== undefined ? { frozenTokenBudget } : {}),
|
|
666
|
+
...(frozenAgentTimeoutMs !== undefined ? { frozenAgentTimeoutMs } : {}),
|
|
409
667
|
...(options.excludeTools ? { frozenExcludeTools: [...options.excludeTools] } : {}),
|
|
410
668
|
status: "pending",
|
|
411
669
|
callStatus: {},
|
|
@@ -413,6 +671,8 @@ export class WorkflowEngine {
|
|
|
413
671
|
attempts: {},
|
|
414
672
|
attemptIds: {},
|
|
415
673
|
callResults: {},
|
|
674
|
+
callTiers: {},
|
|
675
|
+
workflowResultGenerations: {},
|
|
416
676
|
compactions: {},
|
|
417
677
|
startedAt: now,
|
|
418
678
|
updatedAt: now,
|
|
@@ -424,29 +684,35 @@ export class WorkflowEngine {
|
|
|
424
684
|
script,
|
|
425
685
|
scriptHash,
|
|
426
686
|
meta,
|
|
687
|
+
...(journalArgs === undefined ? {} : { args: journalArgs }),
|
|
688
|
+
frozenArgsPresent,
|
|
689
|
+
...(shippedScript === undefined ? {} : { shippedScript }),
|
|
427
690
|
...(toolset ? { toolset } : {}),
|
|
428
|
-
...(
|
|
429
|
-
...(
|
|
430
|
-
...(
|
|
431
|
-
...(
|
|
432
|
-
...(
|
|
691
|
+
...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
|
|
692
|
+
...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
|
|
693
|
+
...(frozenAgentRetries !== undefined ? { frozenAgentRetries } : {}),
|
|
694
|
+
...(frozenTokenBudget !== undefined ? { frozenTokenBudget } : {}),
|
|
695
|
+
...(frozenAgentTimeoutMs !== undefined ? { frozenAgentTimeoutMs } : {}),
|
|
433
696
|
...(options.excludeTools ? { frozenExcludeTools: [...options.excludeTools] } : {}),
|
|
434
697
|
timestamp: now,
|
|
435
698
|
});
|
|
436
699
|
const state: ScriptRunState = {
|
|
437
700
|
run,
|
|
438
701
|
frozenOptions: {
|
|
439
|
-
...(
|
|
440
|
-
...(
|
|
441
|
-
...(
|
|
442
|
-
...(
|
|
443
|
-
...(
|
|
702
|
+
...(frozenTokenBudget !== undefined ? { tokenBudget: frozenTokenBudget } : {}),
|
|
703
|
+
...(frozenMaxAgents !== undefined ? { maxAgents: frozenMaxAgents } : {}),
|
|
704
|
+
...(frozenConcurrency !== undefined ? { concurrency: frozenConcurrency } : {}),
|
|
705
|
+
...(frozenAgentRetries !== undefined ? { agentRetries: frozenAgentRetries } : {}),
|
|
706
|
+
...(frozenAgentTimeoutMs !== undefined ? { agentTimeoutMs: frozenAgentTimeoutMs } : {}),
|
|
444
707
|
...(options.toolset ? { toolset: options.toolset } : {}),
|
|
708
|
+
...(shippedScript === undefined ? {} : { shippedScript }),
|
|
445
709
|
...(options.excludeTools ? { excludeTools: [...options.excludeTools] } : {}),
|
|
446
710
|
},
|
|
447
711
|
controller: new AbortController(),
|
|
448
712
|
generations: new Map(),
|
|
713
|
+
callTiers: new Map(Object.entries(run.callTiers ?? {})),
|
|
449
714
|
agentWaiters: new Map(),
|
|
715
|
+
bufferedTerminals: new Map(),
|
|
450
716
|
pendingSpawns: new Map(),
|
|
451
717
|
pendingJournal: new Set(),
|
|
452
718
|
lifecycleSuspended: false,
|
|
@@ -455,7 +721,7 @@ export class WorkflowEngine {
|
|
|
455
721
|
this.runs.set(runId, state);
|
|
456
722
|
this.setWorkflowStatus(run, "running");
|
|
457
723
|
state.executionGeneration += 1;
|
|
458
|
-
state.execution = this.execute(runId, script,
|
|
724
|
+
state.execution = this.execute(runId, script, startOptions, undefined, state.executionGeneration, protocol);
|
|
459
725
|
// A run-fatal abort/stop already journals its terminal state; never let a
|
|
460
726
|
// leftover rejection become an unhandled promise rejection.
|
|
461
727
|
void state.execution.catch(() => {});
|
|
@@ -468,7 +734,7 @@ export class WorkflowEngine {
|
|
|
468
734
|
runId,
|
|
469
735
|
status: settled.status,
|
|
470
736
|
background: false,
|
|
471
|
-
...(scriptResult !== undefined ? { result:
|
|
737
|
+
...(scriptResult !== undefined ? { result: renderWorkflowValue(scriptResult) } : {}),
|
|
472
738
|
...(settled.error ? { error: settled.error } : {}),
|
|
473
739
|
};
|
|
474
740
|
} catch (error: unknown) {
|
|
@@ -490,6 +756,9 @@ export class WorkflowEngine {
|
|
|
490
756
|
options: ScriptStartOptions = {},
|
|
491
757
|
replacementScript?: string,
|
|
492
758
|
): Promise<ScriptStartResult | undefined> {
|
|
759
|
+
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
760
|
+
const protocol = await this.awaitProtocol();
|
|
761
|
+
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
493
762
|
const state = this.runs.get(runId);
|
|
494
763
|
if (!state) return undefined;
|
|
495
764
|
if (state.lifecycleSuspended) return undefined;
|
|
@@ -521,6 +790,9 @@ export class WorkflowEngine {
|
|
|
521
790
|
}),
|
|
522
791
|
);
|
|
523
792
|
}
|
|
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);
|
|
524
796
|
const resumeJournal = buildResumeJournal(entries);
|
|
525
797
|
// Seed generation bases from journaled attemptIds so a live re-dispatch of a
|
|
526
798
|
// previously-journaled call never reuses a spawnKey (A4).
|
|
@@ -529,22 +801,32 @@ export class WorkflowEngine {
|
|
|
529
801
|
}
|
|
530
802
|
this.setWorkflowStatus(run, "running");
|
|
531
803
|
state.controller = new AbortController();
|
|
532
|
-
// Freeze semantics: original run parameters win over new options.
|
|
533
|
-
// Only signal/confirm/
|
|
534
|
-
// budget/scale/toolset are frozen from the original start.
|
|
804
|
+
// Freeze semantics: original run parameters and args win over new options.
|
|
805
|
+
// Only signal/confirm/background/loadSavedWorkflow/mainModel are per-resume context;
|
|
806
|
+
// input/budget/scale/toolset are frozen from the original start.
|
|
535
807
|
const frozen = state.frozenOptions;
|
|
536
808
|
const resumeOptions: ScriptStartOptions = {
|
|
537
809
|
...options,
|
|
810
|
+
args: resumeArgs,
|
|
538
811
|
tokenBudget: frozen.tokenBudget,
|
|
539
812
|
maxAgents: frozen.maxAgents,
|
|
540
813
|
concurrency: frozen.concurrency,
|
|
541
814
|
agentRetries: frozen.agentRetries,
|
|
542
815
|
agentTimeoutMs: frozen.agentTimeoutMs,
|
|
543
816
|
toolset: frozen.toolset,
|
|
817
|
+
shippedScript: frozen.shippedScript,
|
|
544
818
|
excludeTools: frozen.excludeTools ? [...frozen.excludeTools] : undefined,
|
|
545
819
|
};
|
|
820
|
+
state.bufferedTerminals.clear();
|
|
546
821
|
state.executionGeneration += 1;
|
|
547
|
-
state.execution = this.execute(
|
|
822
|
+
state.execution = this.execute(
|
|
823
|
+
runId,
|
|
824
|
+
run.script,
|
|
825
|
+
resumeOptions,
|
|
826
|
+
resumeJournal,
|
|
827
|
+
state.executionGeneration,
|
|
828
|
+
protocol,
|
|
829
|
+
);
|
|
548
830
|
void state.execution.catch(() => {});
|
|
549
831
|
if (options.background) return { runId, status: run.status, background: true };
|
|
550
832
|
try {
|
|
@@ -561,6 +843,10 @@ export class WorkflowEngine {
|
|
|
561
843
|
runId?: string,
|
|
562
844
|
entries?: readonly SessionEntryLike[],
|
|
563
845
|
): Promise<{ action: string; runs?: unknown[]; run?: unknown }> {
|
|
846
|
+
if (this.disposed) {
|
|
847
|
+
if (action === "list") return { action, runs: [] };
|
|
848
|
+
throw new WorkflowEngineDisposedError();
|
|
849
|
+
}
|
|
564
850
|
if (action === "list") return { action, runs: this.list() };
|
|
565
851
|
if (!runId) throw new Error(`${action} requires runId`);
|
|
566
852
|
const state = this.runs.get(runId);
|
|
@@ -624,6 +910,7 @@ export class WorkflowEngine {
|
|
|
624
910
|
if (!state) throw new Error(`workflow run not found: ${runId}`);
|
|
625
911
|
if (isTerminalWorkflow(state.run.status) || state.run.status === "paused" || state.run.status === "interrupted")
|
|
626
912
|
return state.run;
|
|
913
|
+
if (signal?.aborted) throw new WorkflowWaitAbortedError();
|
|
627
914
|
const all =
|
|
628
915
|
this.waiters.get(runId) ??
|
|
629
916
|
new Set<{
|
|
@@ -689,7 +976,9 @@ export class WorkflowEngine {
|
|
|
689
976
|
options: ScriptStartOptions,
|
|
690
977
|
resumeJournal?: Map<string, JournalEntry>,
|
|
691
978
|
executionGeneration?: number,
|
|
979
|
+
protocol?: ManagedProtocolCheck,
|
|
692
980
|
): Promise<WorkflowRunResult<unknown>> {
|
|
981
|
+
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
693
982
|
const state = this.runs.get(runId);
|
|
694
983
|
if (!state) throw new Error(`workflow run not found: ${runId}`);
|
|
695
984
|
const generation = executionGeneration ?? state.executionGeneration;
|
|
@@ -705,6 +994,12 @@ export class WorkflowEngine {
|
|
|
705
994
|
args: options.args,
|
|
706
995
|
agent: runner,
|
|
707
996
|
mainModel: options.mainModel,
|
|
997
|
+
...(protocol === undefined
|
|
998
|
+
? {}
|
|
999
|
+
: {
|
|
1000
|
+
routingPolicy: protocol.routingPolicy,
|
|
1001
|
+
routingPolicyFingerprint: protocol.routingPolicyFingerprint,
|
|
1002
|
+
}),
|
|
708
1003
|
concurrency: options.concurrency,
|
|
709
1004
|
agentRetries: options.agentRetries,
|
|
710
1005
|
tokenBudget: options.tokenBudget,
|
|
@@ -712,24 +1007,43 @@ export class WorkflowEngine {
|
|
|
712
1007
|
signal: state.controller.signal,
|
|
713
1008
|
maxAgents: options.maxAgents,
|
|
714
1009
|
runId,
|
|
1010
|
+
executionNonce: `${generation}-${randomUUID()}`,
|
|
715
1011
|
resumeJournal,
|
|
716
1012
|
toolset: options.toolset,
|
|
1013
|
+
shippedScript: options.shippedScript,
|
|
717
1014
|
excludeTools: options.excludeTools,
|
|
718
1015
|
confirm: options.confirm,
|
|
719
1016
|
loadSavedWorkflow: options.loadSavedWorkflow,
|
|
720
1017
|
onAgentJournal: (entry) => {
|
|
721
|
-
|
|
722
|
-
//
|
|
723
|
-
//
|
|
1018
|
+
if (state.executionGeneration !== generation) return;
|
|
1019
|
+
// Nested workflow frames keep their own lifecycle visibility, but their
|
|
1020
|
+
// individual call facts are not top-level replay keys. The parent
|
|
1021
|
+
// workflow_result fact below owns the nested replay boundary.
|
|
724
1022
|
if (entry.runId === undefined || entry.runId === runId) this.journalCallResult(runId, entry);
|
|
725
1023
|
},
|
|
726
|
-
|
|
1024
|
+
onWorkflowJournal: (entry) => {
|
|
1025
|
+
if (state.executionGeneration !== generation) return;
|
|
1026
|
+
this.journalWorkflowResult(runId, entry);
|
|
1027
|
+
},
|
|
1028
|
+
onRuntimeEvent: (event) => {
|
|
1029
|
+
if (state.executionGeneration !== generation) return;
|
|
1030
|
+
try {
|
|
1031
|
+
this.events.emit("pi-workflows:runtime", { runId, event: cloneRuntimeEvent(event) });
|
|
1032
|
+
} catch {
|
|
1033
|
+
// Cross-extension observers cannot change workflow execution.
|
|
1034
|
+
}
|
|
1035
|
+
try {
|
|
1036
|
+
this.onRuntimeEvent?.(runId, cloneRuntimeEvent(event));
|
|
1037
|
+
} catch {
|
|
1038
|
+
// UI observers are diagnostic surfaces and are independently contained.
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
727
1041
|
});
|
|
728
1042
|
const live = this.runs.get(runId);
|
|
729
|
-
if (live && state.executionGeneration === generation) live.result = result.result;
|
|
730
1043
|
const run = live?.run;
|
|
731
1044
|
if (run && state.executionGeneration === generation && !isTerminalWorkflow(run.status)) {
|
|
732
|
-
this.setWorkflowStatus(run, "completed");
|
|
1045
|
+
this.setWorkflowStatus(run, "completed", undefined, undefined, result.result);
|
|
1046
|
+
live.result = result.result;
|
|
733
1047
|
}
|
|
734
1048
|
return result;
|
|
735
1049
|
} catch (error: unknown) {
|
|
@@ -758,11 +1072,12 @@ export class WorkflowEngine {
|
|
|
758
1072
|
|
|
759
1073
|
/**
|
|
760
1074
|
* Dispatch one live agent() call through spawn-managed with an A4-rotated
|
|
761
|
-
* spawnKey: `${runId}/call-${callIndex}/attempt-${generation}`.
|
|
762
|
-
*
|
|
763
|
-
*
|
|
1075
|
+
* spawnKey: `${runId}/call-${callIndex}/attempt-${generation}`. Top-level calls
|
|
1076
|
+
* use numeric call indexes; nested workflow frames use a generation/nonce
|
|
1077
|
+
* namespace so a changed child cannot reuse a stale managed identity.
|
|
764
1078
|
*/
|
|
765
1079
|
private async dispatchAgent(runId: string, prompt: string, runOptions: AgentRunOptions): Promise<unknown> {
|
|
1080
|
+
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
766
1081
|
const state = this.runs.get(runId);
|
|
767
1082
|
if (!state) throw new Error(`workflow run not found: ${runId}`);
|
|
768
1083
|
if (state.lifecycleSuspended || state.controller.signal.aborted) {
|
|
@@ -773,13 +1088,19 @@ export class WorkflowEngine {
|
|
|
773
1088
|
const run = state.run;
|
|
774
1089
|
const executionGeneration = state.executionGeneration;
|
|
775
1090
|
const callIndex = runOptions.callIndex ?? state.generations.size;
|
|
1091
|
+
const frameRunId = runOptions.runId ?? runId;
|
|
1092
|
+
const nested = frameRunId !== runId;
|
|
1093
|
+
// Top-level calls use their numeric lexical index. Nested frames use a
|
|
1094
|
+
// run-local namespace so child call 0 can never collide with the parent's
|
|
1095
|
+
// call 0 or with a sibling nested workflow. The managed owner remains the
|
|
1096
|
+
// top-level run: nested workflow results are journaled as one parent fact.
|
|
1097
|
+
const nodeId = nested ? `${frameRunId}:${callIndex}` : String(callIndex);
|
|
776
1098
|
// The spawnKey identity carries the `call-` prefix (A4):
|
|
777
|
-
// spawnKey = `${runId}/call-${
|
|
778
|
-
// The journal node identity is the bare call index
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
const spawnNodeId = `call-${
|
|
782
|
-
const nodeId = String(callIndex);
|
|
1099
|
+
// spawnKey = `${runId}/call-${nodeId}/attempt-${generation}`
|
|
1100
|
+
// The journal node identity is the bare call index for top-level calls;
|
|
1101
|
+
// nested identities are in-memory only and are never persisted as agent
|
|
1102
|
+
// facts.
|
|
1103
|
+
const spawnNodeId = `call-${nodeId}`;
|
|
783
1104
|
const base = state.generations.get(nodeId) ?? 0;
|
|
784
1105
|
const generation = base + (runOptions.attempt ?? 1);
|
|
785
1106
|
state.generations.set(nodeId, generation);
|
|
@@ -787,39 +1108,53 @@ export class WorkflowEngine {
|
|
|
787
1108
|
const attemptId = `${runId}/${nodeId}/attempt-${generation}`;
|
|
788
1109
|
|
|
789
1110
|
const type = runOptions.agentType ?? "general-purpose";
|
|
790
|
-
const tier =
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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;
|
|
794
1125
|
const task: DispatchTask = {
|
|
795
1126
|
subagent_type: type,
|
|
796
1127
|
prompt,
|
|
797
1128
|
description: runOptions.label ?? `workflow call ${callIndex}`,
|
|
798
1129
|
...(tier === undefined ? {} : { tier }),
|
|
799
|
-
...(runOptions.model === undefined ? {} : { model: runOptions.model }),
|
|
800
1130
|
...(runOptions.thread === undefined ? {} : { thread: runOptions.thread }),
|
|
801
1131
|
...(runOptions.toolset === undefined ? {} : { toolset: runOptions.toolset }),
|
|
802
1132
|
...(runOptions.excludeTools === undefined ? {} : { excludeTools: runOptions.excludeTools }),
|
|
803
1133
|
...(runOptions.isolation === undefined ? {} : { isolation: runOptions.isolation }),
|
|
804
1134
|
};
|
|
805
1135
|
|
|
806
|
-
// Journal the attempt rotation
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1136
|
+
// Journal the attempt rotation only for top-level calls. A nested frame is
|
|
1137
|
+
// represented by its parent workflow_result replay boundary instead of
|
|
1138
|
+
// leaking child call facts into the parent's numeric call namespace.
|
|
1139
|
+
if (!nested) {
|
|
1140
|
+
this.persist(runId, `call-attempt:${nodeId}`, () =>
|
|
1141
|
+
this.write({
|
|
1142
|
+
kind: "call_attempt",
|
|
1143
|
+
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
1144
|
+
runId,
|
|
1145
|
+
nodeId,
|
|
1146
|
+
attemptId,
|
|
1147
|
+
generation,
|
|
1148
|
+
...(tierIdentity.tier === undefined ? {} : { tier: tierIdentity.tier }),
|
|
1149
|
+
owner: { extension: "pi-workflows", runId, nodeId, attemptId },
|
|
1150
|
+
timestamp: Date.now(),
|
|
1151
|
+
}),
|
|
1152
|
+
);
|
|
1153
|
+
run.callStatus[nodeId] = "running";
|
|
1154
|
+
run.attempts[nodeId] = generation;
|
|
1155
|
+
run.attemptIds[nodeId] = attemptId;
|
|
1156
|
+
run.updatedAt = Date.now();
|
|
1157
|
+
}
|
|
823
1158
|
|
|
824
1159
|
// Abort linkage: an external signal (pause/stop/Esc) cancels the wait and
|
|
825
1160
|
// stops the owned agent. Track the spawn key before the RPC reply arrives so
|
|
@@ -832,9 +1167,9 @@ export class WorkflowEngine {
|
|
|
832
1167
|
nodeId: spawnNodeId,
|
|
833
1168
|
attemptId: spawnAttemptId,
|
|
834
1169
|
};
|
|
835
|
-
|
|
836
|
-
state.pendingSpawns.set(pendingKey, { spawnKey, owner });
|
|
1170
|
+
state.pendingSpawns.set(spawnKey, { spawnKey, owner, executionGeneration });
|
|
837
1171
|
const reconcilePending = () => {
|
|
1172
|
+
state.bufferedTerminals.delete(spawnKey);
|
|
838
1173
|
const reconciliation = this.client.reconcileManaged?.(spawnKey, owner);
|
|
839
1174
|
void reconciliation?.catch(() => {});
|
|
840
1175
|
};
|
|
@@ -845,10 +1180,10 @@ export class WorkflowEngine {
|
|
|
845
1180
|
let pendingId = "";
|
|
846
1181
|
let response: ManagedSpawnResponse | string;
|
|
847
1182
|
try {
|
|
848
|
-
response = await this.client.spawn(task, runId, spawnNodeId, spawnAttemptId,
|
|
1183
|
+
response = await this.client.spawn(task, runId, spawnNodeId, spawnAttemptId, externalSignal);
|
|
849
1184
|
} finally {
|
|
850
1185
|
externalSignal.removeEventListener("abort", onPendingAbort);
|
|
851
|
-
state.pendingSpawns.delete(
|
|
1186
|
+
state.pendingSpawns.delete(spawnKey);
|
|
852
1187
|
}
|
|
853
1188
|
const responseId = typeof response === "string" ? response : response.id;
|
|
854
1189
|
if (
|
|
@@ -856,6 +1191,7 @@ export class WorkflowEngine {
|
|
|
856
1191
|
state.controller.signal.aborted ||
|
|
857
1192
|
externalSignal.aborted
|
|
858
1193
|
) {
|
|
1194
|
+
state.bufferedTerminals.delete(spawnKey);
|
|
859
1195
|
await this.client
|
|
860
1196
|
.stopOwned?.(responseId, { extension: "pi-workflows", runId, nodeId: spawnNodeId, attemptId: spawnAttemptId })
|
|
861
1197
|
.catch(() => {});
|
|
@@ -865,9 +1201,18 @@ export class WorkflowEngine {
|
|
|
865
1201
|
{ recoverable: true },
|
|
866
1202
|
);
|
|
867
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);
|
|
868
1212
|
if (typeof response === "string") {
|
|
869
1213
|
pendingId = response;
|
|
870
1214
|
} else if (response.terminal) {
|
|
1215
|
+
state.bufferedTerminals.delete(spawnKey);
|
|
871
1216
|
// The spawn response already carries a terminal snapshot (fast completion
|
|
872
1217
|
// or interrupted replay). The runtime journals successful calls itself
|
|
873
1218
|
// (onAgentJournal); a terminal snapshot that is NOT a success is
|
|
@@ -891,9 +1236,10 @@ export class WorkflowEngine {
|
|
|
891
1236
|
terminal.error,
|
|
892
1237
|
terminal.compactionCount,
|
|
893
1238
|
terminal.tokenCount,
|
|
1239
|
+
resolvedTierIdentity?.tier ?? tier,
|
|
894
1240
|
);
|
|
895
1241
|
result.attemptId = attemptId;
|
|
896
|
-
this.commitCallTerminal(runId, nodeId, result);
|
|
1242
|
+
if (!nested) this.commitCallTerminal(runId, nodeId, result);
|
|
897
1243
|
if (status === "failed" && terminal.error) {
|
|
898
1244
|
throw wrapError(new Error(terminal.error), { agentLabel: runOptions.label });
|
|
899
1245
|
}
|
|
@@ -908,6 +1254,7 @@ export class WorkflowEngine {
|
|
|
908
1254
|
if (waiter && !waiter.settled) {
|
|
909
1255
|
waiter.settled = true;
|
|
910
1256
|
state.agentWaiters.delete(pendingId);
|
|
1257
|
+
state.bufferedTerminals.delete(spawnKey);
|
|
911
1258
|
void this.client
|
|
912
1259
|
.stopOwned?.(pendingId, {
|
|
913
1260
|
extension: "pi-workflows",
|
|
@@ -920,10 +1267,11 @@ export class WorkflowEngine {
|
|
|
920
1267
|
}
|
|
921
1268
|
};
|
|
922
1269
|
|
|
923
|
-
// Wait for the terminal lifecycle event
|
|
1270
|
+
// Wait for the terminal lifecycle event, consuming a matching terminal that
|
|
1271
|
+
// arrived while the managed-spawn reply was still pending.
|
|
924
1272
|
const terminal = await new Promise<ManagedSpawnResponse>((resolve, reject) => {
|
|
925
|
-
const waiter = {
|
|
926
|
-
callIndex:
|
|
1273
|
+
const waiter: AgentLifecycleWaiter = {
|
|
1274
|
+
callIndex: nodeId,
|
|
927
1275
|
generation,
|
|
928
1276
|
executionGeneration,
|
|
929
1277
|
onUsage: runOptions.onUsage,
|
|
@@ -934,7 +1282,24 @@ export class WorkflowEngine {
|
|
|
934
1282
|
};
|
|
935
1283
|
state.agentWaiters.set(pendingId, waiter);
|
|
936
1284
|
externalSignal.addEventListener("abort", waitAbort, { once: true });
|
|
937
|
-
if (externalSignal.aborted)
|
|
1285
|
+
if (externalSignal.aborted) {
|
|
1286
|
+
waitAbort();
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const buffered = state.bufferedTerminals.get(spawnKey);
|
|
1291
|
+
if (!buffered) return;
|
|
1292
|
+
state.bufferedTerminals.delete(spawnKey);
|
|
1293
|
+
if (
|
|
1294
|
+
buffered.executionGeneration !== executionGeneration ||
|
|
1295
|
+
state.executionGeneration !== executionGeneration ||
|
|
1296
|
+
state.lifecycleSuspended ||
|
|
1297
|
+
state.controller.signal.aborted ||
|
|
1298
|
+
buffered.response.id !== pendingId ||
|
|
1299
|
+
!sameWorkflowOwner(buffered.owner, owner)
|
|
1300
|
+
)
|
|
1301
|
+
return;
|
|
1302
|
+
this.resolveAgentWaiter(state, pendingId, waiter, buffered);
|
|
938
1303
|
}).finally(() => {
|
|
939
1304
|
externalSignal.removeEventListener("abort", waitAbort);
|
|
940
1305
|
});
|
|
@@ -956,13 +1321,16 @@ export class WorkflowEngine {
|
|
|
956
1321
|
snapshot.error,
|
|
957
1322
|
snapshot.compactionCount,
|
|
958
1323
|
snapshot.tokenCount,
|
|
1324
|
+
state.callTiers.get(nodeId)?.tier ?? tier,
|
|
959
1325
|
);
|
|
960
1326
|
result.attemptId = attemptId;
|
|
961
|
-
this.commitCallTerminal(runId, nodeId, result);
|
|
1327
|
+
if (!nested) this.commitCallTerminal(runId, nodeId, result);
|
|
962
1328
|
if (status === "failed" && snapshot.error) {
|
|
963
1329
|
throw wrapError(new Error(snapshot.error), { agentLabel: runOptions.label });
|
|
964
1330
|
}
|
|
965
1331
|
}
|
|
1332
|
+
const completedTierIdentity = state.callTiers.get(nodeId);
|
|
1333
|
+
if (completedTierIdentity?.tier !== undefined) runOptions.onTierResolved?.(completedTierIdentity.tier);
|
|
966
1334
|
return snapshot.result ?? "";
|
|
967
1335
|
}
|
|
968
1336
|
|
|
@@ -972,9 +1340,12 @@ export class WorkflowEngine {
|
|
|
972
1340
|
const run = state.run;
|
|
973
1341
|
const nodeId = String(entry.index);
|
|
974
1342
|
const result = entry.result;
|
|
1343
|
+
const callTierIdentity: CallTierIdentity =
|
|
1344
|
+
entry.tier === undefined ? (run.callTiers[nodeId] ?? {}) : { tier: entry.tier };
|
|
975
1345
|
const callResult: CallResult = {
|
|
976
1346
|
status: "completed",
|
|
977
1347
|
...(typeof result === "string" && result.length === 0 ? {} : { result }),
|
|
1348
|
+
...callTierIdentity,
|
|
978
1349
|
compactionCount: 0,
|
|
979
1350
|
updatedAt: Date.now(),
|
|
980
1351
|
};
|
|
@@ -996,10 +1367,47 @@ export class WorkflowEngine {
|
|
|
996
1367
|
};
|
|
997
1368
|
this.persist(runId, `call-result:${nodeId}`, () => this.write(event));
|
|
998
1369
|
run.callResults[nodeId] = callResult;
|
|
1370
|
+
run.callTiers[nodeId] = callResult.tier === undefined ? {} : { tier: callResult.tier };
|
|
999
1371
|
run.callStatus[nodeId] = "completed";
|
|
1000
1372
|
run.updatedAt = Date.now();
|
|
1001
1373
|
}
|
|
1002
1374
|
|
|
1375
|
+
private journalWorkflowResult(runId: string, entry: JournalEntry): void {
|
|
1376
|
+
const state = this.runs.get(runId);
|
|
1377
|
+
if (!state) return;
|
|
1378
|
+
const nodeId = String(entry.index);
|
|
1379
|
+
const result: CallResult = {
|
|
1380
|
+
status: "completed",
|
|
1381
|
+
...(entry.result === undefined ? {} : { result: entry.result }),
|
|
1382
|
+
...(entry.tier === undefined ? {} : { tier: entry.tier }),
|
|
1383
|
+
compactionCount: 0,
|
|
1384
|
+
updatedAt: Date.now(),
|
|
1385
|
+
};
|
|
1386
|
+
const event: JournalEvent = {
|
|
1387
|
+
kind: "workflow_result",
|
|
1388
|
+
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
1389
|
+
runId,
|
|
1390
|
+
nodeId,
|
|
1391
|
+
result,
|
|
1392
|
+
generation: entry.generation ?? 1,
|
|
1393
|
+
...(entry.agentCount === undefined ? {} : { agentCount: entry.agentCount }),
|
|
1394
|
+
callHash: entry.hash,
|
|
1395
|
+
...(entry.storeDelta !== undefined && Object.keys(entry.storeDelta).length > 0
|
|
1396
|
+
? { storeDelta: entry.storeDelta }
|
|
1397
|
+
: {}),
|
|
1398
|
+
timestamp: Date.now(),
|
|
1399
|
+
};
|
|
1400
|
+
// The parent cannot become completed until this replay boundary is durable;
|
|
1401
|
+
// otherwise a transient append retry would arrive after the terminal
|
|
1402
|
+
// transition and be discarded as stale during restore.
|
|
1403
|
+
this.appendRequired(event);
|
|
1404
|
+
state.run.workflowResultGenerations[nodeId] = event.generation;
|
|
1405
|
+
state.run.callResults[nodeId] = result;
|
|
1406
|
+
state.run.callTiers[nodeId] = result.tier === undefined ? {} : { tier: result.tier };
|
|
1407
|
+
state.run.callStatus[nodeId] = "completed";
|
|
1408
|
+
state.run.updatedAt = Date.now();
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1003
1411
|
private commitCallTerminal(runId: string, nodeId: string, result: CallResult): void {
|
|
1004
1412
|
const state = this.runs.get(runId);
|
|
1005
1413
|
if (!state) return;
|
|
@@ -1017,6 +1425,7 @@ export class WorkflowEngine {
|
|
|
1017
1425
|
};
|
|
1018
1426
|
this.persist(runId, `call-terminal:${nodeId}`, () => this.write(event));
|
|
1019
1427
|
run.callResults[nodeId] = result;
|
|
1428
|
+
run.callTiers[nodeId] = result.tier === undefined ? {} : { tier: result.tier };
|
|
1020
1429
|
run.callStatus[nodeId] = result.status;
|
|
1021
1430
|
if (result.agentId) run.agentIds[nodeId] = result.agentId;
|
|
1022
1431
|
run.compactions[nodeId] = Math.max(run.compactions[nodeId] ?? 0, result.compactionCount);
|
|
@@ -1031,6 +1440,7 @@ export class WorkflowEngine {
|
|
|
1031
1440
|
void reconciliation?.catch(() => {});
|
|
1032
1441
|
}
|
|
1033
1442
|
state.pendingSpawns.clear();
|
|
1443
|
+
state.bufferedTerminals.clear();
|
|
1034
1444
|
}
|
|
1035
1445
|
|
|
1036
1446
|
private async quiescePendingSpawns(state: ScriptRunState): Promise<WorkflowQuiesceResult> {
|
|
@@ -1093,6 +1503,8 @@ export class WorkflowEngine {
|
|
|
1093
1503
|
undefined,
|
|
1094
1504
|
"workflow stopped",
|
|
1095
1505
|
run.compactions[nodeId] ?? 0,
|
|
1506
|
+
undefined,
|
|
1507
|
+
state.callTiers.get(nodeId)?.tier,
|
|
1096
1508
|
);
|
|
1097
1509
|
result.attemptId = run.attemptIds[nodeId];
|
|
1098
1510
|
terminalResults.push({
|
|
@@ -1183,7 +1595,7 @@ export class WorkflowEngine {
|
|
|
1183
1595
|
this.journal.append(event);
|
|
1184
1596
|
} catch (error: unknown) {
|
|
1185
1597
|
throw new WorkflowError(
|
|
1186
|
-
`Could not persist workflow
|
|
1598
|
+
`Could not persist workflow ${event.kind}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1187
1599
|
WorkflowErrorCode.PERSISTENCE_ERROR,
|
|
1188
1600
|
{ recoverable: true, details: error },
|
|
1189
1601
|
);
|
|
@@ -1247,11 +1659,19 @@ export class WorkflowEngine {
|
|
|
1247
1659
|
this.providerResumeTimers.delete(runId);
|
|
1248
1660
|
const state = this.runs.get(runId);
|
|
1249
1661
|
if (state?.run.status !== "paused") return;
|
|
1250
|
-
void
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
+
});
|
|
1255
1675
|
}, delay);
|
|
1256
1676
|
timer.unref?.();
|
|
1257
1677
|
this.providerResumeTimers.set(runId, timer);
|
|
@@ -1271,8 +1691,13 @@ export class WorkflowEngine {
|
|
|
1271
1691
|
status: ScriptRun["status"],
|
|
1272
1692
|
error?: string,
|
|
1273
1693
|
terminalIntent?: "stop" | "failure",
|
|
1694
|
+
finalResult?: unknown,
|
|
1274
1695
|
): void {
|
|
1275
1696
|
if (isTerminalWorkflow(run.status) && !isTerminalWorkflow(status)) return;
|
|
1697
|
+
// Validate and isolate terminal values before best-effort persistence or any
|
|
1698
|
+
// status mutation. A bad result must still be able to transition the run to failed.
|
|
1699
|
+
const durableFinalResult = finalResult === undefined ? undefined : cloneWorkflowResult(finalResult);
|
|
1700
|
+
const runFinalResult = finalResult === undefined ? undefined : cloneWorkflowResult(finalResult);
|
|
1276
1701
|
const timestamp = Date.now();
|
|
1277
1702
|
this.persist(run.runId, `transition:${status}`, () =>
|
|
1278
1703
|
this.write({
|
|
@@ -1281,15 +1706,18 @@ export class WorkflowEngine {
|
|
|
1281
1706
|
runId: run.runId,
|
|
1282
1707
|
status,
|
|
1283
1708
|
...(error !== undefined ? { error } : {}),
|
|
1709
|
+
...(durableFinalResult !== undefined ? { finalResult: durableFinalResult } : {}),
|
|
1284
1710
|
...(terminalIntent !== undefined ? { terminalIntent } : {}),
|
|
1285
1711
|
timestamp,
|
|
1286
1712
|
}),
|
|
1287
1713
|
);
|
|
1288
1714
|
run.status = status;
|
|
1289
1715
|
if (error !== undefined) run.error = error;
|
|
1716
|
+
if (runFinalResult !== undefined) run.finalResult = runFinalResult;
|
|
1290
1717
|
if (terminalIntent !== undefined) run.terminalIntent = terminalIntent;
|
|
1291
1718
|
run.updatedAt = timestamp;
|
|
1292
1719
|
if (isTerminalWorkflow(status)) {
|
|
1720
|
+
this.runs.get(run.runId)?.bufferedTerminals.clear();
|
|
1293
1721
|
this.settleWaiters(run.runId, run);
|
|
1294
1722
|
this.onRunSettled?.();
|
|
1295
1723
|
}
|
|
@@ -1298,6 +1726,7 @@ export class WorkflowEngine {
|
|
|
1298
1726
|
// ── restore / summary ──────────────────────────────────────────────────────
|
|
1299
1727
|
|
|
1300
1728
|
restore(entries: readonly SessionEntryLike[], branchGeneration = 0): void {
|
|
1729
|
+
if (this.disposed) return;
|
|
1301
1730
|
// A branch replay is a replacement, not a merge. Runs absent from the new
|
|
1302
1731
|
// branch must not remain controllable or append facts into it.
|
|
1303
1732
|
this.clearJournalRetries();
|
|
@@ -1321,11 +1750,14 @@ export class WorkflowEngine {
|
|
|
1321
1750
|
...(run.frozenAgentRetries !== undefined ? { agentRetries: run.frozenAgentRetries } : {}),
|
|
1322
1751
|
...(run.frozenAgentTimeoutMs !== undefined ? { agentTimeoutMs: run.frozenAgentTimeoutMs } : {}),
|
|
1323
1752
|
...(run.toolset ? { toolset: run.toolset } : {}),
|
|
1753
|
+
...(run.shippedScript === undefined ? {} : { shippedScript: run.shippedScript }),
|
|
1324
1754
|
...(run.frozenExcludeTools ? { excludeTools: [...run.frozenExcludeTools] } : {}),
|
|
1325
1755
|
},
|
|
1326
1756
|
controller: new AbortController(),
|
|
1327
1757
|
generations: new Map(),
|
|
1758
|
+
callTiers: new Map(Object.entries(run.callTiers ?? {})),
|
|
1328
1759
|
agentWaiters: new Map(),
|
|
1760
|
+
bufferedTerminals: new Map(),
|
|
1329
1761
|
pendingSpawns: new Map(),
|
|
1330
1762
|
pendingJournal: new Set(),
|
|
1331
1763
|
lifecycleSuspended: false,
|
|
@@ -1374,6 +1806,7 @@ export class WorkflowEngine {
|
|
|
1374
1806
|
startedAt: run.startedAt,
|
|
1375
1807
|
updatedAt: run.updatedAt,
|
|
1376
1808
|
...(run.error ? { error: run.error.slice(0, 2_000) } : {}),
|
|
1809
|
+
...(run.finalResult !== undefined ? { resultPreview: renderWorkflowValue(run.finalResult).slice(0, 8_000) } : {}),
|
|
1377
1810
|
};
|
|
1378
1811
|
if (!detailed) return base;
|
|
1379
1812
|
const calls = Object.entries(run.callStatus)
|
|
@@ -1398,6 +1831,13 @@ function hashScript(script: string): string {
|
|
|
1398
1831
|
return createHash("sha256").update(script).digest("hex");
|
|
1399
1832
|
}
|
|
1400
1833
|
|
|
1834
|
+
export class WorkflowEngineDisposedError extends Error {
|
|
1835
|
+
constructor() {
|
|
1836
|
+
super("Workflow engine is disposed");
|
|
1837
|
+
this.name = "WorkflowEngineDisposedError";
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1401
1841
|
export class WorkflowWaitAbortedError extends Error {
|
|
1402
1842
|
constructor() {
|
|
1403
1843
|
super("workflow wait aborted");
|