@herbertgao/pi-subagents 0.17.1 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +427 -120
- package/docs/rpc.md +184 -0
- package/docs/workflows.md +466 -0
- package/examples/agent-tool-description.md +6 -6
- package/examples/workflows/compose.js +52 -0
- package/examples/workflows/fan-out-audit.js +56 -0
- package/examples/workflows/gated-fix.js +60 -0
- package/examples/workflows/lib/count-child.js +30 -0
- package/examples/workflows/review-panel.js +68 -0
- package/examples/workflows/structured-findings.js +81 -0
- package/package.json +12 -10
- package/src/agent-file-toggle.ts +52 -12
- package/src/agent-manager.ts +837 -146
- package/src/agent-runner.ts +213 -39
- package/src/cross-extension-rpc.ts +73 -14
- package/src/custom-agents.ts +101 -47
- package/src/index.ts +2249 -914
- package/src/invocation-config.ts +13 -0
- package/src/mention-clone.ts +215 -0
- package/src/mention.ts +147 -0
- package/src/model-resolver.ts +9 -1
- package/src/nested-tools.ts +40 -26
- package/src/output-file.ts +18 -8
- package/src/prompts.ts +46 -9
- package/src/schedule.ts +21 -16
- package/src/settings.ts +137 -7
- package/src/structured-output.ts +136 -0
- package/src/types.ts +126 -8
- package/src/ui/agent-mention.ts +274 -0
- package/src/ui/agent-widget.ts +20 -5
- package/src/ui/conversation-viewer.ts +10 -4
- package/src/ui/fleet-list.ts +167 -22
- package/src/ui/workflow-card.ts +555 -0
- package/src/ui/workflow-dialog.ts +1304 -0
- package/src/ui/workflow-menu.ts +226 -0
- package/src/workflow/collisions.ts +122 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +463 -0
- package/src/workflow/journal.ts +164 -0
- package/src/workflow/json-schema.ts +142 -0
- package/src/workflow/meta.ts +401 -0
- package/src/workflow/progress.ts +622 -0
- package/src/workflow/runtime.ts +1399 -0
- package/src/workflow/saved.ts +230 -0
- package/src/workflow/task.ts +333 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +781 -0
- package/src/worktree.ts +97 -95
- package/src/xml.ts +13 -0
|
@@ -0,0 +1,1399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime.ts — the host half of a workflow run.
|
|
3
|
+
*
|
|
4
|
+
* Owns the worker lifecycle, the RPC bridge, the concurrency semaphore, the
|
|
5
|
+
* per-run caps, and the progress log. The script's only route to an agent is a
|
|
6
|
+
* `call` message landing here, which is what makes the caps and the abort story
|
|
7
|
+
* enforceable at all: a script cannot go around them because it has nothing to
|
|
8
|
+
* go around them *with*.
|
|
9
|
+
*
|
|
10
|
+
* Spawning is injected rather than imported. `AgentManager` is a large, stateful
|
|
11
|
+
* dependency and wiring it in directly would make every test here an integration
|
|
12
|
+
* test; a {@link WorkflowHost} stub is a dozen lines. The adapter that binds this
|
|
13
|
+
* to the real manager lives at the call site.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { cpus } from "node:os"
|
|
17
|
+
import { Worker } from "node:worker_threads"
|
|
18
|
+
import {
|
|
19
|
+
type JournalKeyInput,
|
|
20
|
+
journalKey,
|
|
21
|
+
type WorkflowJournalEntry,
|
|
22
|
+
} from "./journal.js"
|
|
23
|
+
import { type CompiledSchema, compileJsonSchema } from "./json-schema.js"
|
|
24
|
+
import { extractMeta, type WorkflowMeta } from "./meta.js"
|
|
25
|
+
import type { WorkflowAgentEntry, WorkflowEntry } from "./progress.js"
|
|
26
|
+
import { WORKER_SOURCE } from "./worker-source.js"
|
|
27
|
+
|
|
28
|
+
/** Matches the `script` field's `maxLength` in the tool schema. */
|
|
29
|
+
export const MAX_SCRIPT_LENGTH = 524_288
|
|
30
|
+
|
|
31
|
+
/** Agents one run may schedule, in total. */
|
|
32
|
+
export const WORKFLOW_AGENT_CAP = 1000
|
|
33
|
+
|
|
34
|
+
/** Items one `parallel()` or `pipeline()` call may take. */
|
|
35
|
+
export const WORKFLOW_ITEM_CAP = 4096
|
|
36
|
+
|
|
37
|
+
/** Nested `workflow()` invocations allowed per run. */
|
|
38
|
+
export const WORKFLOW_NESTED_CAP = 256
|
|
39
|
+
|
|
40
|
+
/** How much of a prompt or result is kept for the UI. */
|
|
41
|
+
const PREVIEW_LENGTH = 200
|
|
42
|
+
|
|
43
|
+
export class WorkflowRuntimeError extends Error {}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Concurrent agents allowed, leaving two cores for the host and the TUI.
|
|
47
|
+
*
|
|
48
|
+
* `Math.max(1, …)` is not decoration: the raw `min(16, cpus - 2)` is 0 on a one-
|
|
49
|
+
* or two-core machine, and a semaphore with zero permits never hands out a slot,
|
|
50
|
+
* so the run would hang before its first agent rather than fail.
|
|
51
|
+
*/
|
|
52
|
+
export function workflowConcurrency(cpuCount: number = cpus().length): number {
|
|
53
|
+
return Math.max(1, Math.min(16, cpuCount - 2))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One agent the script asked for. `agentId` is the handle for {@link WorkflowHost.abortAgent}. */
|
|
57
|
+
export interface WorkflowSpawnRequest {
|
|
58
|
+
agentId: string
|
|
59
|
+
/** Position in the run, and the progress entry's stable identity. */
|
|
60
|
+
index: number
|
|
61
|
+
prompt: string
|
|
62
|
+
label: string
|
|
63
|
+
agentType: string
|
|
64
|
+
model?: string
|
|
65
|
+
/**
|
|
66
|
+
* Reasoning effort for this child, as one of pi's thinking levels.
|
|
67
|
+
*
|
|
68
|
+
* Typed as a plain string because this interface is the host boundary and
|
|
69
|
+
* deliberately knows nothing about pi — `host.ts` is where it becomes a
|
|
70
|
+
* `ThinkingLevel`. The worker has already rejected anything off the list.
|
|
71
|
+
*/
|
|
72
|
+
effort?: string
|
|
73
|
+
isolation?: "worktree"
|
|
74
|
+
/**
|
|
75
|
+
* Called by the host once the child's EFFECTIVE configuration is known —
|
|
76
|
+
* which is when its session exists, not when the spawn resolves.
|
|
77
|
+
*
|
|
78
|
+
* Without it a row could only ever show what the script asked for: a fuzzy
|
|
79
|
+
* `model: "haiku"` stays `haiku` instead of the id it resolved to, an
|
|
80
|
+
* `agent()` that named no model shows nothing at all, and a level pi clamped
|
|
81
|
+
* is presented as the level that was requested (#168, #182).
|
|
82
|
+
*
|
|
83
|
+
* Plain strings, like `effort` above: this interface is the host boundary and
|
|
84
|
+
* deliberately knows nothing about pi's `AgentInvocation`. Optional, so a host
|
|
85
|
+
* that cannot report any of this simply does not, and the row keeps the
|
|
86
|
+
* requested values it started with.
|
|
87
|
+
*/
|
|
88
|
+
onResolved?(info: {
|
|
89
|
+
/**
|
|
90
|
+
* The host's own id for the child — the manager's `AgentRecord` id here.
|
|
91
|
+
*
|
|
92
|
+
* Reported as soon as the host has one, which is earlier than the rest of
|
|
93
|
+
* this: the model is knowable only once a session exists, but the id is
|
|
94
|
+
* what lets a reader open that child's conversation, and a child that
|
|
95
|
+
* never got a model is exactly the one worth opening.
|
|
96
|
+
*/
|
|
97
|
+
recordId?: string
|
|
98
|
+
modelName?: string
|
|
99
|
+
modelId?: string
|
|
100
|
+
thinking?: string
|
|
101
|
+
requestedThinking?: string
|
|
102
|
+
requestedModel?: string
|
|
103
|
+
}): void
|
|
104
|
+
/**
|
|
105
|
+
* Compiled from the script's `agent({ schema })`.
|
|
106
|
+
*
|
|
107
|
+
* The host must give the child a `StructuredOutput` tool built from it and
|
|
108
|
+
* return the validated payload as JSON text. Compiled rather than raw so the
|
|
109
|
+
* runtime can re-check the answer without re-parsing the schema per call.
|
|
110
|
+
*/
|
|
111
|
+
schema?: CompiledSchema
|
|
112
|
+
phaseIndex?: number
|
|
113
|
+
phaseTitle?: string
|
|
114
|
+
/**
|
|
115
|
+
* The `gate` command this agent is being spawned under, when it has one.
|
|
116
|
+
*
|
|
117
|
+
* Passed down rather than run purely from here because an isolated child's
|
|
118
|
+
* worktree is destroyed as part of its own settle: a host that can reach
|
|
119
|
+
* inside that settle runs the gate there, against the tree the child wrote,
|
|
120
|
+
* and reports the outcome back as {@link WorkflowSpawnResult.gate}. A host
|
|
121
|
+
* that ignores this leaves the gate to {@link applyGate}, which then runs it
|
|
122
|
+
* itself — so exactly one execution either way.
|
|
123
|
+
*/
|
|
124
|
+
gate?: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface WorkflowSpawnResult {
|
|
128
|
+
ok: boolean
|
|
129
|
+
/** The agent's answer. Present when `ok`. */
|
|
130
|
+
text?: string
|
|
131
|
+
/** Why it failed. Present when not `ok`. */
|
|
132
|
+
error?: string
|
|
133
|
+
/** The user dismissed it rather than it failing; renders as skipped. */
|
|
134
|
+
skipped?: boolean
|
|
135
|
+
tokens?: number
|
|
136
|
+
/**
|
|
137
|
+
* Output tokens only, for the script's `budget.spent()`.
|
|
138
|
+
*
|
|
139
|
+
* Separate from {@link tokens}, which is the lifetime total. Claude Code's
|
|
140
|
+
* budget counts output, and a fan-out's re-sent input would swamp it.
|
|
141
|
+
*/
|
|
142
|
+
outputTokens?: number
|
|
143
|
+
/** Whether the child needed an extra prompt to produce its structured answer. */
|
|
144
|
+
structuredRetried?: boolean
|
|
145
|
+
toolCalls?: number
|
|
146
|
+
/**
|
|
147
|
+
* Where the child actually ran.
|
|
148
|
+
*
|
|
149
|
+
* Only meaningful for `isolation: "worktree"`, and the whole reason it exists:
|
|
150
|
+
* a gate has to run against the tree the child edited, not the main one, or it
|
|
151
|
+
* verifies the wrong working copy. Left unset, a gate runs wherever the host
|
|
152
|
+
* runs commands by default.
|
|
153
|
+
*
|
|
154
|
+
* Usually unset for a worktree child even so: the copy is removed during the
|
|
155
|
+
* child's own settle, so it no longer exists by the time this is read. That
|
|
156
|
+
* is what {@link gate} is for.
|
|
157
|
+
*/
|
|
158
|
+
cwd?: string
|
|
159
|
+
/**
|
|
160
|
+
* The outcome of this agent's `gate`, when the host already ran it.
|
|
161
|
+
*
|
|
162
|
+
* Set only by a host that ran the command itself — inside the child's
|
|
163
|
+
* worktree, while that directory still existed. Its presence is what tells
|
|
164
|
+
* {@link applyGate} the command has already been executed; the pass/fail
|
|
165
|
+
* decision and the error shaping still happen there, in one place.
|
|
166
|
+
*/
|
|
167
|
+
gate?: WorkflowGateResult
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Outcome of a `gate` command. `output` is what the user is shown when it fails. */
|
|
171
|
+
export interface WorkflowGateResult {
|
|
172
|
+
ok: boolean
|
|
173
|
+
/** Combined stdout/stderr, or whatever the host wants surfaced as the failure. */
|
|
174
|
+
output: string
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The one seam between a workflow and the rest of the extension. */
|
|
178
|
+
/** How a script names another workflow: a saved name, or a path to a file. */
|
|
179
|
+
export interface WorkflowScriptRef {
|
|
180
|
+
name?: string
|
|
181
|
+
scriptPath?: string
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type WorkflowScriptSource =
|
|
185
|
+
| { ok: true; script: string; path?: string }
|
|
186
|
+
| { ok: false; message: string }
|
|
187
|
+
|
|
188
|
+
export interface WorkflowHost {
|
|
189
|
+
spawnAgent(request: WorkflowSpawnRequest): Promise<WorkflowSpawnResult>
|
|
190
|
+
/** Called for every in-flight agent when the run aborts. */
|
|
191
|
+
abortAgent(agentId: string): void
|
|
192
|
+
/**
|
|
193
|
+
* Continue a child that already ran in this run, keeping its context.
|
|
194
|
+
*
|
|
195
|
+
* `agentId` is one previously handed out in a {@link WorkflowSpawnRequest};
|
|
196
|
+
* the child keeps the agent type, model and tool contract it started with, so
|
|
197
|
+
* only the follow-up prompt crosses.
|
|
198
|
+
*
|
|
199
|
+
* Optional: a host without it rejects `resume` rather than quietly starting a
|
|
200
|
+
* fresh child that has none of the context the script is counting on.
|
|
201
|
+
*/
|
|
202
|
+
resumeAgent?(
|
|
203
|
+
agentId: string,
|
|
204
|
+
prompt: string,
|
|
205
|
+
/**
|
|
206
|
+
* Same reporter {@link WorkflowSpawnRequest.onResolved} carries, for the
|
|
207
|
+
* same reason: a resumed row is rebuilt from scratch, so without it the
|
|
208
|
+
* continuation of a child would show the model the script *asked* for while
|
|
209
|
+
* the row above it shows the one that ran.
|
|
210
|
+
*/
|
|
211
|
+
onResolved?: WorkflowSpawnRequest["onResolved"],
|
|
212
|
+
): Promise<WorkflowSpawnResult>
|
|
213
|
+
/**
|
|
214
|
+
* Run a `gate` command and report whether it passed.
|
|
215
|
+
*
|
|
216
|
+
* `cwd` is the child's worktree when it had one. Optional for the same reason
|
|
217
|
+
* as {@link resumeAgent}, and more sharply: a gate that silently does not run
|
|
218
|
+
* would mark unverified work as verified, so the runtime fails the call
|
|
219
|
+
* instead of skipping it.
|
|
220
|
+
*/
|
|
221
|
+
runGate?(
|
|
222
|
+
command: string,
|
|
223
|
+
options: { agentId: string; cwd?: string },
|
|
224
|
+
): Promise<WorkflowGateResult>
|
|
225
|
+
/**
|
|
226
|
+
* Resolve a nested `workflow()` reference to source.
|
|
227
|
+
*
|
|
228
|
+
* The runtime knows nothing about the filesystem or about pi, so it asks. It
|
|
229
|
+
* still decides whether what comes back *is* a workflow — see
|
|
230
|
+
* {@link validateScript} — because those rules belong with the runtime that
|
|
231
|
+
* enforces them everywhere else.
|
|
232
|
+
*
|
|
233
|
+
* Optional for the same reason as {@link resumeAgent}: a host without it
|
|
234
|
+
* rejects `workflow()` outright rather than silently running nothing.
|
|
235
|
+
*/
|
|
236
|
+
loadWorkflow?(
|
|
237
|
+
ref: WorkflowScriptRef,
|
|
238
|
+
): Promise<WorkflowScriptSource> | WorkflowScriptSource
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* What a run can be told to do while it is going, from the workflows dialog.
|
|
243
|
+
*
|
|
244
|
+
* Every method is best-effort and idempotent: the dialog renders off a progress
|
|
245
|
+
* log that lags the runtime slightly, so it will sometimes ask for something
|
|
246
|
+
* that has just stopped being possible. `false` means "there was nothing to do
|
|
247
|
+
* that to" — a caller can say so, but it is never an error.
|
|
248
|
+
*/
|
|
249
|
+
export interface WorkflowControl {
|
|
250
|
+
/**
|
|
251
|
+
* Stop *starting* agents. Ones already running are left to finish, because
|
|
252
|
+
* killing model work mid-turn throws away everything it has spent and there
|
|
253
|
+
* is no way to hand it back its context.
|
|
254
|
+
*/
|
|
255
|
+
pause(): void
|
|
256
|
+
resume(): void
|
|
257
|
+
isPaused(): boolean
|
|
258
|
+
/**
|
|
259
|
+
* Give up on the agent at `index`: its `agent()` call returns `null`, exactly
|
|
260
|
+
* as a terminal failure does, and the row renders skipped.
|
|
261
|
+
*
|
|
262
|
+
* Immediate for a running agent and for one held at a pause. An agent parked
|
|
263
|
+
* behind the concurrency limit takes its skip when it reaches the front —
|
|
264
|
+
* the alternative is a cancellable semaphore for a case that resolves itself
|
|
265
|
+
* as soon as any sibling finishes.
|
|
266
|
+
*/
|
|
267
|
+
skip(index: number): boolean
|
|
268
|
+
/**
|
|
269
|
+
* Start the agent at `index` over: the child is stopped and the same call is
|
|
270
|
+
* re-run, so the script's `agent()` promise is still the one waiting and it
|
|
271
|
+
* gets the new answer.
|
|
272
|
+
*
|
|
273
|
+
* Only while it is running — that is the whole window. Once the call has
|
|
274
|
+
* settled its value is already the script's, and re-running would produce a
|
|
275
|
+
* result with nowhere to go.
|
|
276
|
+
*/
|
|
277
|
+
retry(index: number): boolean
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface RunWorkflowOptions {
|
|
281
|
+
/** Full script source, starting with `export const meta = { … }`. */
|
|
282
|
+
script: string
|
|
283
|
+
args?: unknown
|
|
284
|
+
host: WorkflowHost
|
|
285
|
+
signal?: AbortSignal
|
|
286
|
+
/** Fired per batch, not per entry — see the worker's progress batching. */
|
|
287
|
+
onProgress?(entries: readonly WorkflowEntry[]): void
|
|
288
|
+
concurrency?: number
|
|
289
|
+
agentCap?: number
|
|
290
|
+
itemCap?: number
|
|
291
|
+
/**
|
|
292
|
+
* Hands the caller the run's control surface, once per run.
|
|
293
|
+
*
|
|
294
|
+
* A callback rather than a return value because `runWorkflow` resolves when
|
|
295
|
+
* the run is *over*, which is the one moment there is nothing left to
|
|
296
|
+
* control. Fired before the first agent starts.
|
|
297
|
+
*/
|
|
298
|
+
onControl?(control: WorkflowControl): void
|
|
299
|
+
/**
|
|
300
|
+
* How many nested `workflow()` invocations one run may make in total.
|
|
301
|
+
*
|
|
302
|
+
* Each costs a compile and a scope rather than a thread, so the ceiling is
|
|
303
|
+
* generous — but unbounded is worse than capped, on the same reasoning as
|
|
304
|
+
* {@link agentCap}.
|
|
305
|
+
*/
|
|
306
|
+
nestedCap?: number
|
|
307
|
+
/**
|
|
308
|
+
* Replay and record, for `resumeFromRunId`.
|
|
309
|
+
*
|
|
310
|
+
* The runtime does no file IO — `entries` come in already read and `append`
|
|
311
|
+
* goes back out — so its tests stay free of a filesystem, the same reason
|
|
312
|
+
* spawning is behind {@link WorkflowHost}.
|
|
313
|
+
*/
|
|
314
|
+
journal?: {
|
|
315
|
+
/** A previous run's settled calls, in position order. Empty replays nothing. */
|
|
316
|
+
entries?: readonly WorkflowJournalEntry[]
|
|
317
|
+
/** Called as each call of *this* run settles, so it can be resumed in turn. */
|
|
318
|
+
append?(entry: WorkflowJournalEntry): void
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface WorkflowRunResult {
|
|
323
|
+
status: "completed" | "failed" | "killed"
|
|
324
|
+
meta: WorkflowMeta
|
|
325
|
+
/** The script's return value, JSON-checked at the boundary. */
|
|
326
|
+
value?: unknown
|
|
327
|
+
error?: string
|
|
328
|
+
/** The append-only log, in emission order. */
|
|
329
|
+
progress: WorkflowEntry[]
|
|
330
|
+
/** Agents scheduled, including those that failed. */
|
|
331
|
+
agentCount: number
|
|
332
|
+
/** How many of those came back from the journal instead of being spawned. */
|
|
333
|
+
replayedCount: number
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* ------------------------------------------------------------------------- *
|
|
337
|
+
* JSON boundary — host side
|
|
338
|
+
* ------------------------------------------------------------------------- */
|
|
339
|
+
|
|
340
|
+
function boundaryError(what: string, path: string): WorkflowRuntimeError {
|
|
341
|
+
return new WorkflowRuntimeError(
|
|
342
|
+
`Cannot pass ${what} across the workflow VM boundary (at ${path}).`,
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function walk(value: unknown, path: string, seen: Set<object>): void {
|
|
347
|
+
if (value === null) return
|
|
348
|
+
const kind = typeof value
|
|
349
|
+
if (kind === "string" || kind === "boolean") return
|
|
350
|
+
if (kind === "number") {
|
|
351
|
+
if (!Number.isFinite(value))
|
|
352
|
+
throw boundaryError("a non-finite number", path)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
if (kind === "undefined") {
|
|
356
|
+
if (path === "args") return
|
|
357
|
+
throw boundaryError("undefined", path)
|
|
358
|
+
}
|
|
359
|
+
if (kind === "bigint") throw boundaryError("a BigInt", path)
|
|
360
|
+
if (kind === "symbol") throw boundaryError("a symbol", path)
|
|
361
|
+
if (kind === "function") throw boundaryError("a function", path)
|
|
362
|
+
if (kind !== "object") throw boundaryError(`a ${kind}`, path)
|
|
363
|
+
|
|
364
|
+
const object = value as object
|
|
365
|
+
if (seen.has(object)) throw boundaryError("a circular structure", path)
|
|
366
|
+
seen.add(object)
|
|
367
|
+
|
|
368
|
+
if (Object.getOwnPropertySymbols(object).length > 0) {
|
|
369
|
+
throw boundaryError("an object with symbol keys", path)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (Array.isArray(object)) {
|
|
373
|
+
for (let i = 0; i < object.length; i++) {
|
|
374
|
+
if (!Object.hasOwn(object, i))
|
|
375
|
+
throw boundaryError("a sparse array", `${path}[${i}]`)
|
|
376
|
+
walk(object[i], `${path}[${i}]`, seen)
|
|
377
|
+
}
|
|
378
|
+
seen.delete(object)
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const prototype = Object.getPrototypeOf(object)
|
|
383
|
+
if (prototype !== null && prototype !== Object.prototype) {
|
|
384
|
+
throw boundaryError("a non-plain object", path)
|
|
385
|
+
}
|
|
386
|
+
for (const [key, entry] of Object.entries(object)) {
|
|
387
|
+
walk(entry, `${path}.${key}`, seen)
|
|
388
|
+
}
|
|
389
|
+
seen.delete(object)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Reject anything that cannot survive the round trip to the worker and into a
|
|
394
|
+
* resume journal. Structured clone would happily carry a `Map` or a cycle that
|
|
395
|
+
* the journal then cannot represent, so the check is stricter than the transport.
|
|
396
|
+
*/
|
|
397
|
+
export function assertBoundarySafe(value: unknown, path: string): void {
|
|
398
|
+
walk(value, path, new Set())
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/* ------------------------------------------------------------------------- *
|
|
402
|
+
* Semaphore
|
|
403
|
+
* ------------------------------------------------------------------------- */
|
|
404
|
+
|
|
405
|
+
class Semaphore {
|
|
406
|
+
private active = 0
|
|
407
|
+
private readonly waiters: (() => void)[] = []
|
|
408
|
+
|
|
409
|
+
constructor(private readonly limit: number) {}
|
|
410
|
+
|
|
411
|
+
acquire(): Promise<void> {
|
|
412
|
+
if (this.active < this.limit) {
|
|
413
|
+
this.active++
|
|
414
|
+
return Promise.resolve()
|
|
415
|
+
}
|
|
416
|
+
return new Promise<void>((resolve) => {
|
|
417
|
+
this.waiters.push(resolve)
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
release(): void {
|
|
422
|
+
const next = this.waiters.shift()
|
|
423
|
+
// Hand the permit straight over rather than decrementing and re-acquiring;
|
|
424
|
+
// otherwise a burst of releases can let more than `limit` through.
|
|
425
|
+
if (next) next()
|
|
426
|
+
else this.active--
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Wake everyone so aborted callers can observe the abort and bail. */
|
|
430
|
+
drain(): void {
|
|
431
|
+
while (this.waiters.length > 0) {
|
|
432
|
+
const next = this.waiters.shift()
|
|
433
|
+
next?.()
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/* ------------------------------------------------------------------------- *
|
|
439
|
+
* Messages
|
|
440
|
+
* ------------------------------------------------------------------------- */
|
|
441
|
+
|
|
442
|
+
interface AgentCallPayload {
|
|
443
|
+
prompt: string
|
|
444
|
+
label?: string
|
|
445
|
+
model?: string
|
|
446
|
+
agentType?: string
|
|
447
|
+
isolation?: "worktree"
|
|
448
|
+
phaseIndex?: number
|
|
449
|
+
phaseTitle?: string
|
|
450
|
+
/** Shell command that has to pass before the agent counts as done. */
|
|
451
|
+
gate?: string
|
|
452
|
+
/** Label of an earlier child in this run to continue instead of starting one. */
|
|
453
|
+
resume?: string
|
|
454
|
+
/** Reasoning effort, already validated against pi's thinking levels worker-side. */
|
|
455
|
+
effort?: string
|
|
456
|
+
/** Raw JSON Schema from `agent({ schema })`, compiled before anything spawns. */
|
|
457
|
+
schema?: unknown
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
type WorkerMessage =
|
|
461
|
+
| { type: "call"; callId: number; method: string; payload: AgentCallPayload }
|
|
462
|
+
| { type: "progress"; entries: WorkflowEntry[] }
|
|
463
|
+
| { type: "complete"; resultJson?: string }
|
|
464
|
+
| { type: "error"; message: string; stack?: string }
|
|
465
|
+
|
|
466
|
+
/** Everything below 0x20 except tab, newline and carriage return, plus DEL. */
|
|
467
|
+
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/
|
|
468
|
+
|
|
469
|
+
const preview = (text: string) =>
|
|
470
|
+
text.length <= PREVIEW_LENGTH ? text : `${text.slice(0, PREVIEW_LENGTH - 1)}…`
|
|
471
|
+
|
|
472
|
+
/** First line of the prompt, trimmed — the fallback display name for an agent. */
|
|
473
|
+
function derivedLabel(prompt: string): string {
|
|
474
|
+
const line = prompt.split("\n", 1)[0].trim()
|
|
475
|
+
return line.length <= 60 ? line || "agent" : `${line.slice(0, 59)}…`
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* A child `resume` can revive, remembered under its label.
|
|
480
|
+
*
|
|
481
|
+
* The spawn options travel with it because `resume` deliberately takes none: the
|
|
482
|
+
* revived child keeps the agent type, model and isolation it was started with,
|
|
483
|
+
* and the progress entry has to show the same thing the first entry showed.
|
|
484
|
+
*/
|
|
485
|
+
interface CompletedChild {
|
|
486
|
+
agentId: string
|
|
487
|
+
label: string
|
|
488
|
+
agentType: string
|
|
489
|
+
model?: string
|
|
490
|
+
isolation?: "worktree"
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Turn a failing gate into a failing agent.
|
|
495
|
+
*
|
|
496
|
+
* Deliberately no new state, no new entry type: a gated agent whose command
|
|
497
|
+
* fails is *a failed agent*, so the card, the dialog and `agent()`'s `null`
|
|
498
|
+
* return all handle it with the code they already have. The command output
|
|
499
|
+
* becomes the error, because that is the thing worth reading.
|
|
500
|
+
*
|
|
501
|
+
* The single place that decides whether a gate passed. The command may have
|
|
502
|
+
* been run by the host instead (inside a worktree that no longer exists by
|
|
503
|
+
* now), but only ever by one of the two: a host that ran it says so with
|
|
504
|
+
* `result.gate`, and this then shapes that outcome rather than running it
|
|
505
|
+
* again.
|
|
506
|
+
*/
|
|
507
|
+
/**
|
|
508
|
+
* Hold a schema'd result to its schema, host-side.
|
|
509
|
+
*
|
|
510
|
+
* The child's own tool already validated whatever it passed, so this normally
|
|
511
|
+
* agrees. It exists for the cases where nothing did: a host that ignores
|
|
512
|
+
* `schema` entirely, a replayed journal entry from before the schema changed,
|
|
513
|
+
* or a payload that reached us some other way. The script asked for a shape;
|
|
514
|
+
* exactly one place should be able to promise it.
|
|
515
|
+
*/
|
|
516
|
+
function applySchema(
|
|
517
|
+
result: WorkflowSpawnResult,
|
|
518
|
+
compiled: CompiledSchema,
|
|
519
|
+
): WorkflowSpawnResult {
|
|
520
|
+
let parsed: unknown
|
|
521
|
+
try {
|
|
522
|
+
parsed = JSON.parse(result.text ?? "")
|
|
523
|
+
} catch {
|
|
524
|
+
return {
|
|
525
|
+
...result,
|
|
526
|
+
ok: false,
|
|
527
|
+
error:
|
|
528
|
+
"The agent did not return structured output: its answer was not JSON.",
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const verdict = compiled.check(parsed)
|
|
532
|
+
if (verdict === true) return result
|
|
533
|
+
return {
|
|
534
|
+
...result,
|
|
535
|
+
ok: false,
|
|
536
|
+
error: `The agent's answer did not match the requested schema: ${verdict}`,
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function applyGate(
|
|
541
|
+
result: WorkflowSpawnResult,
|
|
542
|
+
command: string,
|
|
543
|
+
agentId: string,
|
|
544
|
+
runGate: NonNullable<WorkflowHost["runGate"]>,
|
|
545
|
+
): Promise<WorkflowSpawnResult> {
|
|
546
|
+
const outcome =
|
|
547
|
+
result.gate ??
|
|
548
|
+
(await runGate(command, {
|
|
549
|
+
agentId,
|
|
550
|
+
// Where the child worked, when it had a worktree of its own. Gating the
|
|
551
|
+
// main tree instead would verify code the child never touched.
|
|
552
|
+
...(result.cwd !== undefined ? { cwd: result.cwd } : {}),
|
|
553
|
+
}))
|
|
554
|
+
const { gate: _ran, ...kept } = result
|
|
555
|
+
if (outcome.ok) return kept
|
|
556
|
+
const { text: _discarded, ...rest } = kept
|
|
557
|
+
const output = outcome.output.trim()
|
|
558
|
+
return {
|
|
559
|
+
...rest,
|
|
560
|
+
ok: false,
|
|
561
|
+
error: output === "" ? `Gate command failed: ${command}` : output,
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Nico's wording, kept verbatim — this is the one borrowed check whose message a
|
|
567
|
+
* user is likely to search for.
|
|
568
|
+
*/
|
|
569
|
+
function unawaitedLaunchMessage(labels: readonly string[]): string {
|
|
570
|
+
const list = labels.map((label) => `'${label}'`).join(", ")
|
|
571
|
+
return `workflow script completed with unawaited agent launch(es): ${list}. Await or return each launch.`
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Run one workflow script to completion.
|
|
576
|
+
*
|
|
577
|
+
* Rejects before starting for a script that cannot run at all (bad `meta`, over
|
|
578
|
+
* the size limit, control characters, non-JSON `args`). Everything after the
|
|
579
|
+
* worker is live resolves instead, carrying the failure in `status` — by then
|
|
580
|
+
* there is a progress log worth handing back.
|
|
581
|
+
*/
|
|
582
|
+
/**
|
|
583
|
+
* Everything a script must satisfy before it is compiled.
|
|
584
|
+
*
|
|
585
|
+
* Extracted so a nested `workflow()` is held to exactly the same standard as a
|
|
586
|
+
* top-level run: same size limit, same character rules, same `meta` contract.
|
|
587
|
+
* The host resolves a reference to source; deciding whether that source is a
|
|
588
|
+
* workflow stays here, where the rules live.
|
|
589
|
+
*/
|
|
590
|
+
export function validateScript(script: string): {
|
|
591
|
+
meta: WorkflowMeta
|
|
592
|
+
body: string
|
|
593
|
+
} {
|
|
594
|
+
if (script.length > MAX_SCRIPT_LENGTH) {
|
|
595
|
+
throw new WorkflowRuntimeError(
|
|
596
|
+
`Workflow script is ${script.length} characters, over the limit of ${MAX_SCRIPT_LENGTH}.`,
|
|
597
|
+
)
|
|
598
|
+
}
|
|
599
|
+
if (CONTROL_CHARACTERS.test(script)) {
|
|
600
|
+
throw new WorkflowRuntimeError(
|
|
601
|
+
"Workflow script contains control characters. Only tab, carriage return and newline are allowed.",
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
return extractMeta(script)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export async function runWorkflow(
|
|
608
|
+
options: RunWorkflowOptions,
|
|
609
|
+
): Promise<WorkflowRunResult> {
|
|
610
|
+
const { script, host } = options
|
|
611
|
+
|
|
612
|
+
assertBoundarySafe(options.args, "args")
|
|
613
|
+
|
|
614
|
+
const { meta, body } = validateScript(script)
|
|
615
|
+
const agentCap = options.agentCap ?? WORKFLOW_AGENT_CAP
|
|
616
|
+
const itemCap = options.itemCap ?? WORKFLOW_ITEM_CAP
|
|
617
|
+
const semaphore = new Semaphore(options.concurrency ?? workflowConcurrency())
|
|
618
|
+
|
|
619
|
+
const progress: WorkflowEntry[] = []
|
|
620
|
+
const inflight = new Set<string>()
|
|
621
|
+
/** Label → the child that ran under it, last one wins. The `resume` handle. */
|
|
622
|
+
const completedByLabel = new Map<string, CompletedChild>()
|
|
623
|
+
/**
|
|
624
|
+
* Launches the host has accepted and not yet answered, in call order.
|
|
625
|
+
*
|
|
626
|
+
* This is the whole unawaited-launch mechanism: a script that drops an
|
|
627
|
+
* `agent()` promise still gets its call answered eventually, but it returns
|
|
628
|
+
* first — so anything left here when `complete` arrives is a result nobody is
|
|
629
|
+
* waiting for. Tracking it host-side avoids proxying `Promise` inside the
|
|
630
|
+
* realm, which §2.4 rules out, and reading stack traces, which is brittle.
|
|
631
|
+
*/
|
|
632
|
+
const openLaunches = new Map<number, string>()
|
|
633
|
+
let agentCount = 0
|
|
634
|
+
let aborted = false
|
|
635
|
+
let settled = false
|
|
636
|
+
|
|
637
|
+
/* --- resume state ---------------------------------------------------- */
|
|
638
|
+
|
|
639
|
+
const journalEntries = options.journal?.entries ?? []
|
|
640
|
+
const recordJournal = options.journal?.append
|
|
641
|
+
/**
|
|
642
|
+
* Whether the replayable prefix is still intact.
|
|
643
|
+
*
|
|
644
|
+
* Once a position misses — different key, a journaled failure, or nothing
|
|
645
|
+
* recorded there — every later call runs live, however well it matches.
|
|
646
|
+
* See the header of journal.ts for why this is a prefix and not a lookup.
|
|
647
|
+
*/
|
|
648
|
+
// A journal from a run that used `agent({ resume })` is declined whole: see
|
|
649
|
+
// journal.ts on why a replayed agent leaves nothing for a later resume to
|
|
650
|
+
// continue. Declining up front beats stranding the first `resume` call
|
|
651
|
+
// partway through a run that has already spent its cheap half.
|
|
652
|
+
const journalResumes = journalEntries.some((entry) => entry.resumed)
|
|
653
|
+
let prefixIntact = journalEntries.length > 0 && !journalResumes
|
|
654
|
+
let replayedCount = 0
|
|
655
|
+
|
|
656
|
+
/* --- live control ---------------------------------------------------- */
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Agents that still have an unanswered `agent()` call, by index.
|
|
660
|
+
*
|
|
661
|
+
* The window in which skip and retry mean anything: before the entry appears
|
|
662
|
+
* there is nothing to act on, and after it is gone the script already has its
|
|
663
|
+
* value. `started` is what separates the two — a retry needs a child to stop.
|
|
664
|
+
*/
|
|
665
|
+
interface LiveAgent {
|
|
666
|
+
agentId: string
|
|
667
|
+
started: boolean
|
|
668
|
+
intent?: "skip" | "retry"
|
|
669
|
+
/** Wakes it out of a pause hold, so a skip does not wait for a resume. */
|
|
670
|
+
wake?: () => void
|
|
671
|
+
}
|
|
672
|
+
const liveAgents = new Map<number, LiveAgent>()
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Output tokens this run has spent, mirrored to the script as
|
|
676
|
+
* `budget.spent()`.
|
|
677
|
+
*
|
|
678
|
+
* The host owns the number and every response carries it, rather than the
|
|
679
|
+
* worker accumulating its own: two counters would drift, and there is nothing
|
|
680
|
+
* to gain from the second one. Nor is there observable staleness — tokens
|
|
681
|
+
* only accrue through agents, and the script only learns anything through
|
|
682
|
+
* agent responses.
|
|
683
|
+
*/
|
|
684
|
+
let spentOutputTokens = 0
|
|
685
|
+
|
|
686
|
+
let paused = false
|
|
687
|
+
/** Read through a call for the same reason `intent()` is — see below. */
|
|
688
|
+
const isPaused = () => paused
|
|
689
|
+
const pauseWaiters = new Set<() => void>()
|
|
690
|
+
/** Release everyone held at a pause — on resume, and on the way out. */
|
|
691
|
+
function releasePause(): void {
|
|
692
|
+
for (const wake of [...pauseWaiters]) wake()
|
|
693
|
+
pauseWaiters.clear()
|
|
694
|
+
}
|
|
695
|
+
/** Park here while the run is paused, so no new agent is started. */
|
|
696
|
+
function pauseGate(live: LiveAgent): Promise<void> {
|
|
697
|
+
if (!paused || aborted || settled) return Promise.resolve()
|
|
698
|
+
return new Promise<void>((resolve) => {
|
|
699
|
+
const wake = () => {
|
|
700
|
+
pauseWaiters.delete(wake)
|
|
701
|
+
live.wake = undefined
|
|
702
|
+
resolve()
|
|
703
|
+
}
|
|
704
|
+
live.wake = wake
|
|
705
|
+
pauseWaiters.add(wake)
|
|
706
|
+
})
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
options.onControl?.({
|
|
710
|
+
pause: () => {
|
|
711
|
+
paused = true
|
|
712
|
+
},
|
|
713
|
+
resume: () => {
|
|
714
|
+
paused = false
|
|
715
|
+
releasePause()
|
|
716
|
+
},
|
|
717
|
+
isPaused: () => paused,
|
|
718
|
+
skip: (index) => {
|
|
719
|
+
const live = liveAgents.get(index)
|
|
720
|
+
if (live === undefined || live.intent !== undefined) return false
|
|
721
|
+
live.intent = "skip"
|
|
722
|
+
// A running child is stopped, which comes back as a skipped result; a
|
|
723
|
+
// held one is woken so it can bail at the gate it is parked on.
|
|
724
|
+
if (live.started) host.abortAgent(live.agentId)
|
|
725
|
+
else live.wake?.()
|
|
726
|
+
return true
|
|
727
|
+
},
|
|
728
|
+
retry: (index) => {
|
|
729
|
+
const live = liveAgents.get(index)
|
|
730
|
+
if (live === undefined || !live.started || live.intent !== undefined)
|
|
731
|
+
return false
|
|
732
|
+
live.intent = "retry"
|
|
733
|
+
host.abortAgent(live.agentId)
|
|
734
|
+
return true
|
|
735
|
+
},
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
/** The journal entry to reuse at `index`, or undefined to run it live. */
|
|
739
|
+
function replayAt(
|
|
740
|
+
index: number,
|
|
741
|
+
key: string,
|
|
742
|
+
): WorkflowJournalEntry | undefined {
|
|
743
|
+
if (!prefixIntact) return undefined
|
|
744
|
+
const entry = journalEntries[index]
|
|
745
|
+
if (
|
|
746
|
+
entry === undefined ||
|
|
747
|
+
entry.index !== index ||
|
|
748
|
+
entry.key !== key ||
|
|
749
|
+
!entry.ok
|
|
750
|
+
) {
|
|
751
|
+
prefixIntact = false
|
|
752
|
+
return undefined
|
|
753
|
+
}
|
|
754
|
+
return entry
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const worker = new Worker(WORKER_SOURCE, {
|
|
758
|
+
eval: true,
|
|
759
|
+
workerData: {
|
|
760
|
+
body,
|
|
761
|
+
metaJson: JSON.stringify(meta),
|
|
762
|
+
argsJson:
|
|
763
|
+
options.args === undefined ? undefined : JSON.stringify(options.args),
|
|
764
|
+
itemCap,
|
|
765
|
+
nestedCap: options.nestedCap ?? WORKFLOW_NESTED_CAP,
|
|
766
|
+
},
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
return await new Promise<WorkflowRunResult>((resolve) => {
|
|
770
|
+
const emit = (entries: WorkflowEntry[]) => {
|
|
771
|
+
if (entries.length === 0) return
|
|
772
|
+
progress.push(...entries)
|
|
773
|
+
options.onProgress?.(entries)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const respond = (
|
|
777
|
+
callId: number,
|
|
778
|
+
ok: boolean,
|
|
779
|
+
value?: unknown,
|
|
780
|
+
error?: string,
|
|
781
|
+
fatal?: boolean,
|
|
782
|
+
) => {
|
|
783
|
+
// Cleared before the settled check: a launch answered by a run that is
|
|
784
|
+
// already finishing is not an unawaited launch either.
|
|
785
|
+
openLaunches.delete(callId)
|
|
786
|
+
if (settled) return
|
|
787
|
+
// `spent` rides on every response, so the worker's `budget.spent()` is a
|
|
788
|
+
// mirror of this number rather than a second tally of its own.
|
|
789
|
+
worker.postMessage({
|
|
790
|
+
type: "response",
|
|
791
|
+
callId,
|
|
792
|
+
ok,
|
|
793
|
+
value,
|
|
794
|
+
error,
|
|
795
|
+
fatal,
|
|
796
|
+
spent: spentOutputTokens,
|
|
797
|
+
})
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const finish = (
|
|
801
|
+
result: Omit<
|
|
802
|
+
WorkflowRunResult,
|
|
803
|
+
"meta" | "progress" | "agentCount" | "replayedCount"
|
|
804
|
+
>,
|
|
805
|
+
) => {
|
|
806
|
+
if (settled) return
|
|
807
|
+
settled = true
|
|
808
|
+
options.signal?.removeEventListener("abort", onAbort)
|
|
809
|
+
// Symmetric with `semaphore.drain()` below: everything parked is woken so
|
|
810
|
+
// it observes the settle and unwinds. Nothing depends on it — the run's
|
|
811
|
+
// promise resolves either way — it just does not leave live-agent
|
|
812
|
+
// bookkeeping behind for a run that is over.
|
|
813
|
+
releasePause()
|
|
814
|
+
for (const agentId of inflight) host.abortAgent(agentId)
|
|
815
|
+
inflight.clear()
|
|
816
|
+
semaphore.drain()
|
|
817
|
+
// Resolve only once the thread is actually down, so a caller that awaits
|
|
818
|
+
// runWorkflow() is guaranteed not to be leaking one.
|
|
819
|
+
const settle = () =>
|
|
820
|
+
resolve({ ...result, meta, progress, agentCount, replayedCount })
|
|
821
|
+
void worker.terminate().then(settle, settle)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function onAbort() {
|
|
825
|
+
aborted = true
|
|
826
|
+
// terminate() is why this runs in a worker at all: it stops a script that
|
|
827
|
+
// is spinning or wedged mid-await, which an in-process vm cannot do.
|
|
828
|
+
finish({ status: "killed", error: "Workflow aborted." })
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (options.signal) {
|
|
832
|
+
if (options.signal.aborted) {
|
|
833
|
+
onAbort()
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
options.signal.addEventListener("abort", onAbort, { once: true })
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async function handleAgent(
|
|
840
|
+
callId: number,
|
|
841
|
+
payload: AgentCallPayload,
|
|
842
|
+
): Promise<void> {
|
|
843
|
+
// Bound now: the optional methods are checked once, up front, so a
|
|
844
|
+
// capability the host lacks fails before an agent is spawned rather than
|
|
845
|
+
// after — a gate that never ran must not be mistaken for a gate that
|
|
846
|
+
// passed.
|
|
847
|
+
const runGate = host.runGate?.bind(host)
|
|
848
|
+
const resumeAgent = host.resumeAgent?.bind(host)
|
|
849
|
+
if (payload.gate !== undefined && runGate === undefined) {
|
|
850
|
+
respond(
|
|
851
|
+
callId,
|
|
852
|
+
false,
|
|
853
|
+
undefined,
|
|
854
|
+
"This workflow host cannot run gate commands.",
|
|
855
|
+
true,
|
|
856
|
+
)
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
if (payload.resume !== undefined && resumeAgent === undefined) {
|
|
860
|
+
respond(
|
|
861
|
+
callId,
|
|
862
|
+
false,
|
|
863
|
+
undefined,
|
|
864
|
+
"This workflow host cannot resume agents.",
|
|
865
|
+
true,
|
|
866
|
+
)
|
|
867
|
+
return
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
let resumed: CompletedChild | undefined
|
|
871
|
+
if (payload.resume !== undefined) {
|
|
872
|
+
resumed = completedByLabel.get(payload.resume)
|
|
873
|
+
if (resumed === undefined) {
|
|
874
|
+
const known = [...completedByLabel.keys()]
|
|
875
|
+
// Fatal: a typo'd label is a script bug, and folding it into a null
|
|
876
|
+
// would show up as an agent that mysteriously returned nothing.
|
|
877
|
+
//
|
|
878
|
+
// Unless agents were replayed, in which case it is not a script bug
|
|
879
|
+
// at all — the label's child came back from the journal and has no
|
|
880
|
+
// conversation here to continue. Saying "no agent has completed"
|
|
881
|
+
// would send the reader hunting for a typo that is not there.
|
|
882
|
+
respond(
|
|
883
|
+
callId,
|
|
884
|
+
false,
|
|
885
|
+
undefined,
|
|
886
|
+
replayedCount > 0
|
|
887
|
+
? `agent() opts.resume: "${payload.resume}" was replayed from the resume journal, not run, so there is ` +
|
|
888
|
+
"no conversation in this run to continue. Re-run without resumeFromRunId."
|
|
889
|
+
: `agent() opts.resume: no agent has completed under the label "${payload.resume}" in this run. ${
|
|
890
|
+
known.length === 0
|
|
891
|
+
? "No agent has completed yet."
|
|
892
|
+
: `Known labels: ${known.map((label) => `"${label}"`).join(", ")}.`
|
|
893
|
+
}`,
|
|
894
|
+
true,
|
|
895
|
+
)
|
|
896
|
+
return
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Compiled before anything is scheduled. A schema the runtime cannot use
|
|
901
|
+
// is a script bug, so it is fatal like a typo'd resume label — folding it
|
|
902
|
+
// into a null would surface as an agent that mysteriously returned
|
|
903
|
+
// nothing, and it costs no model call to say so here.
|
|
904
|
+
let compiledSchema: CompiledSchema | undefined
|
|
905
|
+
if (payload.schema !== undefined) {
|
|
906
|
+
const compilation = compileJsonSchema(payload.schema)
|
|
907
|
+
if (!compilation.ok) {
|
|
908
|
+
respond(callId, false, undefined, compilation.message, true)
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
compiledSchema = compilation.compiled
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (agentCount >= agentCap) {
|
|
915
|
+
// Fatal, so parallel()/pipeline() rethrow instead of folding it into a
|
|
916
|
+
// null. A cap that silently drops work is worse than no cap.
|
|
917
|
+
respond(
|
|
918
|
+
callId,
|
|
919
|
+
false,
|
|
920
|
+
undefined,
|
|
921
|
+
`Workflow exceeded its cap of ${agentCap} agents.`,
|
|
922
|
+
true,
|
|
923
|
+
)
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
const index = agentCount++
|
|
927
|
+
// A resumed call is the same child again: it keeps the agent id, so an
|
|
928
|
+
// abort still reaches it, and it keeps its spawn contract, so the row
|
|
929
|
+
// reads the same as the row it continues.
|
|
930
|
+
const agentId = resumed?.agentId ?? `wf-agent-${index}`
|
|
931
|
+
const label =
|
|
932
|
+
payload.label ?? resumed?.label ?? derivedLabel(payload.prompt)
|
|
933
|
+
const agentType =
|
|
934
|
+
resumed?.agentType ?? payload.agentType ?? "general-purpose"
|
|
935
|
+
const model = resumed !== undefined ? resumed.model : payload.model
|
|
936
|
+
const isolation =
|
|
937
|
+
resumed !== undefined ? resumed.isolation : payload.isolation
|
|
938
|
+
openLaunches.set(callId, label)
|
|
939
|
+
|
|
940
|
+
const base: WorkflowAgentEntry = {
|
|
941
|
+
type: "workflow_agent",
|
|
942
|
+
index,
|
|
943
|
+
label,
|
|
944
|
+
state: "start",
|
|
945
|
+
agentId,
|
|
946
|
+
agentType,
|
|
947
|
+
promptPreview: preview(payload.prompt),
|
|
948
|
+
...(model !== undefined ? { model } : {}),
|
|
949
|
+
...(isolation !== undefined ? { isolation } : {}),
|
|
950
|
+
...(payload.phaseIndex !== undefined
|
|
951
|
+
? { phaseIndex: payload.phaseIndex }
|
|
952
|
+
: {}),
|
|
953
|
+
...(payload.phaseTitle !== undefined
|
|
954
|
+
? { phaseTitle: payload.phaseTitle }
|
|
955
|
+
: {}),
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const queuedAt = Date.now()
|
|
959
|
+
emit([{ ...base, queuedAt }])
|
|
960
|
+
|
|
961
|
+
// Replay before the semaphore, not after: a cached answer is not model
|
|
962
|
+
// running, so it must not hold a concurrency slot that a live agent
|
|
963
|
+
// could use. The row still appears in the tree — the run reads as the
|
|
964
|
+
// same shape it had the first time, just faster.
|
|
965
|
+
// The payload's `schema` is the raw object; the key wants it serialized,
|
|
966
|
+
// so the spread is narrowed rather than passed through.
|
|
967
|
+
const keyInput: JournalKeyInput = {
|
|
968
|
+
...payload,
|
|
969
|
+
schema:
|
|
970
|
+
payload.schema !== undefined
|
|
971
|
+
? JSON.stringify(payload.schema)
|
|
972
|
+
: undefined,
|
|
973
|
+
}
|
|
974
|
+
let replayed = replayAt(index, journalKey(keyInput))
|
|
975
|
+
// A replayed answer still has to satisfy the schema. The key covers a
|
|
976
|
+
// schema that *changed*, but not a journal that was hand-edited, and not
|
|
977
|
+
// the empty text a torn entry leaves behind — either would hand the
|
|
978
|
+
// script a null from an entry the journal claims succeeded.
|
|
979
|
+
if (replayed !== undefined && compiledSchema !== undefined) {
|
|
980
|
+
const recheck = applySchema(
|
|
981
|
+
{ ok: true, text: replayed.text ?? "" },
|
|
982
|
+
compiledSchema,
|
|
983
|
+
)
|
|
984
|
+
if (!recheck.ok) {
|
|
985
|
+
prefixIntact = false
|
|
986
|
+
replayed = undefined
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
if (replayed !== undefined) {
|
|
990
|
+
replayedCount++
|
|
991
|
+
const replayedText = replayed.text ?? ""
|
|
992
|
+
const at = Date.now()
|
|
993
|
+
emit([
|
|
994
|
+
{
|
|
995
|
+
...base,
|
|
996
|
+
queuedAt,
|
|
997
|
+
startedAt: at,
|
|
998
|
+
lastProgressAt: at,
|
|
999
|
+
durationMs: 0,
|
|
1000
|
+
state: "done",
|
|
1001
|
+
// The row reads as done, because it is — `cached` is what tells the
|
|
1002
|
+
// dialog to annotate it "from resume journal" rather than letting a
|
|
1003
|
+
// 0ms agent look like one that did the work impossibly fast.
|
|
1004
|
+
cached: true,
|
|
1005
|
+
resultPreview: preview(replayedText),
|
|
1006
|
+
},
|
|
1007
|
+
])
|
|
1008
|
+
openLaunches.delete(callId)
|
|
1009
|
+
// Re-recorded so this run's journal is complete on its own terms: a
|
|
1010
|
+
// resume of a resume must not have to walk back through a chain of
|
|
1011
|
+
// earlier files to find the prefix.
|
|
1012
|
+
recordJournal?.({
|
|
1013
|
+
index,
|
|
1014
|
+
key: replayed.key,
|
|
1015
|
+
ok: true,
|
|
1016
|
+
text: replayedText,
|
|
1017
|
+
})
|
|
1018
|
+
respond(callId, true, replayedText)
|
|
1019
|
+
return
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const key = journalKey(keyInput)
|
|
1023
|
+
const resumeMark =
|
|
1024
|
+
payload.resume !== undefined ? ({ resumed: true } as const) : {}
|
|
1025
|
+
|
|
1026
|
+
/** A skip the user asked for, before the child ever started. */
|
|
1027
|
+
const settleSkipped = (extra: Partial<WorkflowAgentEntry>) => {
|
|
1028
|
+
recordJournal?.({ index, key, ok: false, ...resumeMark })
|
|
1029
|
+
emit([
|
|
1030
|
+
{
|
|
1031
|
+
...base,
|
|
1032
|
+
queuedAt,
|
|
1033
|
+
...extra,
|
|
1034
|
+
state: "error",
|
|
1035
|
+
skipped: true,
|
|
1036
|
+
error: "Skipped by user.",
|
|
1037
|
+
},
|
|
1038
|
+
])
|
|
1039
|
+
// `null`, exactly as a terminal failure gives — a skipped agent is one
|
|
1040
|
+
// the script's `.filter(Boolean)` was already written to survive.
|
|
1041
|
+
respond(callId, true, null)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Registered for exactly as long as the call is unanswered, which is the
|
|
1045
|
+
// window in which skip and retry mean anything.
|
|
1046
|
+
const live: LiveAgent = { agentId, started: false }
|
|
1047
|
+
liveAgents.set(index, live)
|
|
1048
|
+
// Read through a call, not off the field: `intent` is set from outside
|
|
1049
|
+
// this function while it is suspended at an await, so control-flow
|
|
1050
|
+
// narrowing across the awaits would be reasoning about a value that has
|
|
1051
|
+
// since changed.
|
|
1052
|
+
const intent = (): LiveAgent["intent"] => live.intent
|
|
1053
|
+
let attempt = 1
|
|
1054
|
+
try {
|
|
1055
|
+
for (;;) {
|
|
1056
|
+
// Held before the slot, not after: a paused run must not sit on
|
|
1057
|
+
// concurrency it is not using while its running agents drain.
|
|
1058
|
+
await pauseGate(live)
|
|
1059
|
+
if (intent() === "skip") return settleSkipped({})
|
|
1060
|
+
|
|
1061
|
+
// A resumed agent waits its turn like any other: it is the same amount of
|
|
1062
|
+
// model running at once.
|
|
1063
|
+
await semaphore.acquire()
|
|
1064
|
+
if (aborted || settled) {
|
|
1065
|
+
semaphore.release()
|
|
1066
|
+
respond(callId, false, undefined, "Workflow aborted.", true)
|
|
1067
|
+
return
|
|
1068
|
+
}
|
|
1069
|
+
// Paused while parked behind the limit: this agent was waiting for a
|
|
1070
|
+
// permit when the pause landed, so it never passed the gate above.
|
|
1071
|
+
// Hand the permit back and go wait at the gate like everything else,
|
|
1072
|
+
// or a pause would leak exactly as many agents as were queued.
|
|
1073
|
+
if (isPaused() && !aborted && !settled) {
|
|
1074
|
+
semaphore.release()
|
|
1075
|
+
continue
|
|
1076
|
+
}
|
|
1077
|
+
// Skipped while parked behind the limit: the permit arrived, and the
|
|
1078
|
+
// only thing left to do with it is give it back.
|
|
1079
|
+
if (intent() === "skip") {
|
|
1080
|
+
semaphore.release()
|
|
1081
|
+
return settleSkipped({})
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Carried on every emit from here on, so a retried row keeps saying
|
|
1085
|
+
// why it is on its second attempt instead of losing it to the next
|
|
1086
|
+
// progress update.
|
|
1087
|
+
const attemptMark =
|
|
1088
|
+
attempt > 1
|
|
1089
|
+
? { attempt, lastAttemptReason: "user-retry" as const }
|
|
1090
|
+
: {}
|
|
1091
|
+
|
|
1092
|
+
const startedAt = Date.now()
|
|
1093
|
+
emit([{ ...base, queuedAt, startedAt, ...attemptMark }])
|
|
1094
|
+
|
|
1095
|
+
// Mutates `base` rather than emitting a standalone patch: every later
|
|
1096
|
+
// emit spreads it, so the settle path carries the effective values
|
|
1097
|
+
// without knowing they were ever corrected. Re-emitting under the
|
|
1098
|
+
// same `index` is what the append-only, last-write-wins progress log
|
|
1099
|
+
// is for — the row updates in place while the agent is still running.
|
|
1100
|
+
const onResolved = (info: {
|
|
1101
|
+
recordId?: string
|
|
1102
|
+
modelName?: string
|
|
1103
|
+
modelId?: string
|
|
1104
|
+
thinking?: string
|
|
1105
|
+
requestedThinking?: string
|
|
1106
|
+
requestedModel?: string
|
|
1107
|
+
}) => {
|
|
1108
|
+
if (info.recordId !== undefined) base.recordId = info.recordId
|
|
1109
|
+
if (info.modelName !== undefined) base.model = info.modelName
|
|
1110
|
+
if (info.modelId !== undefined) base.modelId = info.modelId
|
|
1111
|
+
if (info.thinking !== undefined) base.thinking = info.thinking
|
|
1112
|
+
if (info.requestedThinking !== undefined)
|
|
1113
|
+
base.requestedThinking = info.requestedThinking
|
|
1114
|
+
if (info.requestedModel !== undefined)
|
|
1115
|
+
base.requestedModel = info.requestedModel
|
|
1116
|
+
// `base.state` is still "start", so emitting after the row reached a
|
|
1117
|
+
// terminal state would revert it to running under last-write-wins.
|
|
1118
|
+
// Not reachable from this repo's host, which reports during startup
|
|
1119
|
+
// — but this is the host boundary, and every other promise it makes
|
|
1120
|
+
// is checked rather than trusted.
|
|
1121
|
+
if (!inflight.has(agentId)) return
|
|
1122
|
+
emit([
|
|
1123
|
+
{
|
|
1124
|
+
...base,
|
|
1125
|
+
queuedAt,
|
|
1126
|
+
startedAt,
|
|
1127
|
+
...attemptMark,
|
|
1128
|
+
lastProgressAt: Date.now(),
|
|
1129
|
+
},
|
|
1130
|
+
])
|
|
1131
|
+
}
|
|
1132
|
+
live.started = true
|
|
1133
|
+
inflight.add(agentId)
|
|
1134
|
+
|
|
1135
|
+
let result: WorkflowSpawnResult
|
|
1136
|
+
try {
|
|
1137
|
+
result =
|
|
1138
|
+
resumed !== undefined && resumeAgent !== undefined
|
|
1139
|
+
? await resumeAgent(resumed.agentId, payload.prompt, onResolved)
|
|
1140
|
+
: await host.spawnAgent({
|
|
1141
|
+
agentId,
|
|
1142
|
+
index,
|
|
1143
|
+
prompt: payload.prompt,
|
|
1144
|
+
label,
|
|
1145
|
+
agentType,
|
|
1146
|
+
...(model !== undefined ? { model } : {}),
|
|
1147
|
+
...(payload.effort !== undefined
|
|
1148
|
+
? { effort: payload.effort }
|
|
1149
|
+
: {}),
|
|
1150
|
+
...(compiledSchema !== undefined
|
|
1151
|
+
? { schema: compiledSchema }
|
|
1152
|
+
: {}),
|
|
1153
|
+
...(isolation !== undefined ? { isolation } : {}),
|
|
1154
|
+
...(payload.phaseIndex !== undefined
|
|
1155
|
+
? { phaseIndex: payload.phaseIndex }
|
|
1156
|
+
: {}),
|
|
1157
|
+
...(payload.phaseTitle !== undefined
|
|
1158
|
+
? { phaseTitle: payload.phaseTitle }
|
|
1159
|
+
: {}),
|
|
1160
|
+
// Offered, not delegated: a host that can run it inside the
|
|
1161
|
+
// child's worktree does, and hands back `result.gate`.
|
|
1162
|
+
...(payload.gate !== undefined
|
|
1163
|
+
? { gate: payload.gate }
|
|
1164
|
+
: {}),
|
|
1165
|
+
onResolved,
|
|
1166
|
+
})
|
|
1167
|
+
if (result.ok) {
|
|
1168
|
+
// Recorded before the gate runs: the child itself finished, so it is
|
|
1169
|
+
// resumable even when its gate rejects the work — "here is what the
|
|
1170
|
+
// gate said, fix it" is the loop this exists for.
|
|
1171
|
+
completedByLabel.set(label, {
|
|
1172
|
+
agentId,
|
|
1173
|
+
label,
|
|
1174
|
+
agentType,
|
|
1175
|
+
...(model !== undefined ? { model } : {}),
|
|
1176
|
+
...(isolation !== undefined ? { isolation } : {}),
|
|
1177
|
+
})
|
|
1178
|
+
// Re-checked here, not just in the child's tool: this is the one
|
|
1179
|
+
// place that decides the script's value matches the schema it
|
|
1180
|
+
// asked for, so a host that ignored `schema` fails loudly instead
|
|
1181
|
+
// of handing the script prose. Before the gate, because a gate
|
|
1182
|
+
// verifies work and there is no work to verify if the shape is
|
|
1183
|
+
// wrong — and the reader should see the schema error, not a gate
|
|
1184
|
+
// error standing in front of it.
|
|
1185
|
+
if (compiledSchema !== undefined && result.ok) {
|
|
1186
|
+
result = applySchema(result, compiledSchema)
|
|
1187
|
+
}
|
|
1188
|
+
if (
|
|
1189
|
+
result.ok &&
|
|
1190
|
+
payload.gate !== undefined &&
|
|
1191
|
+
runGate !== undefined
|
|
1192
|
+
) {
|
|
1193
|
+
result = await applyGate(result, payload.gate, agentId, runGate)
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
result = {
|
|
1198
|
+
ok: false,
|
|
1199
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1200
|
+
}
|
|
1201
|
+
} finally {
|
|
1202
|
+
inflight.delete(agentId)
|
|
1203
|
+
live.started = false
|
|
1204
|
+
semaphore.release()
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (settled) return
|
|
1208
|
+
|
|
1209
|
+
// The stop that produced this result was ours, so run the same call
|
|
1210
|
+
// again rather than reporting it. The script is still awaiting this
|
|
1211
|
+
// `agent()`, which is the only reason a retry can mean anything.
|
|
1212
|
+
if (intent() === "retry" && !aborted) {
|
|
1213
|
+
live.intent = undefined
|
|
1214
|
+
attempt++
|
|
1215
|
+
emit([
|
|
1216
|
+
{ ...base, queuedAt, attempt, lastAttemptReason: "user-retry" },
|
|
1217
|
+
])
|
|
1218
|
+
continue
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// Counted before the response is sent, so the very call that spent
|
|
1222
|
+
// them already sees them in `budget.spent()`. Failed and skipped
|
|
1223
|
+
// agents count too — they burned the tokens either way.
|
|
1224
|
+
spentOutputTokens += result.outputTokens ?? 0
|
|
1225
|
+
|
|
1226
|
+
const finishedAt = Date.now()
|
|
1227
|
+
const common = {
|
|
1228
|
+
...base,
|
|
1229
|
+
queuedAt,
|
|
1230
|
+
startedAt,
|
|
1231
|
+
...attemptMark,
|
|
1232
|
+
lastProgressAt: finishedAt,
|
|
1233
|
+
durationMs: finishedAt - startedAt,
|
|
1234
|
+
...(result.tokens !== undefined ? { tokens: result.tokens } : {}),
|
|
1235
|
+
...(result.toolCalls !== undefined
|
|
1236
|
+
? { toolCalls: result.toolCalls }
|
|
1237
|
+
: {}),
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (result.ok) {
|
|
1241
|
+
const text = result.text ?? ""
|
|
1242
|
+
emit([{ ...common, state: "done", resultPreview: preview(text) }])
|
|
1243
|
+
recordJournal?.({ index, key, ok: true, text, ...resumeMark })
|
|
1244
|
+
respond(callId, true, text)
|
|
1245
|
+
return
|
|
1246
|
+
}
|
|
1247
|
+
// Recorded as a failure rather than left out: a gap would be read as an
|
|
1248
|
+
// unchanged prefix on the next resume, silently skipping the retry this
|
|
1249
|
+
// whole mechanism exists to make cheap.
|
|
1250
|
+
recordJournal?.({ index, key, ok: false, ...resumeMark })
|
|
1251
|
+
// A dead agent is a null in the script, not a thrown error: Claude Code
|
|
1252
|
+
// scripts .filter(Boolean) rather than try/catch around every call.
|
|
1253
|
+
emit([
|
|
1254
|
+
{
|
|
1255
|
+
...common,
|
|
1256
|
+
state: "error",
|
|
1257
|
+
// A user skip reaches here as a stopped child, which the host
|
|
1258
|
+
// already reports as skipped — the flag is taken from the result
|
|
1259
|
+
// rather than from the intent so an abort mid-skip still reads
|
|
1260
|
+
// as whatever actually happened to the child.
|
|
1261
|
+
error: result.error ?? "Agent failed.",
|
|
1262
|
+
...(result.skipped ? { skipped: true } : {}),
|
|
1263
|
+
},
|
|
1264
|
+
])
|
|
1265
|
+
respond(callId, true, null)
|
|
1266
|
+
return
|
|
1267
|
+
}
|
|
1268
|
+
} finally {
|
|
1269
|
+
liveAgents.delete(index)
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Resolve one `workflow(ref)` and hand the child's source back compiled.
|
|
1275
|
+
*
|
|
1276
|
+
* Resolution failures are non-fatal — Claude Code documents `workflow()` as
|
|
1277
|
+
* throwing on an unknown name so a script can catch it and carry on. A host
|
|
1278
|
+
* with no `loadWorkflow` at all is fatal, matching how a missing `runGate`
|
|
1279
|
+
* or `resumeAgent` is treated: a capability the script asked for and this
|
|
1280
|
+
* host cannot provide is a wiring error, not a runtime condition.
|
|
1281
|
+
*/
|
|
1282
|
+
async function handleLoadWorkflow(
|
|
1283
|
+
callId: number,
|
|
1284
|
+
ref: WorkflowScriptRef,
|
|
1285
|
+
): Promise<void> {
|
|
1286
|
+
const loadWorkflow = host.loadWorkflow?.bind(host)
|
|
1287
|
+
if (loadWorkflow === undefined) {
|
|
1288
|
+
respond(
|
|
1289
|
+
callId,
|
|
1290
|
+
false,
|
|
1291
|
+
undefined,
|
|
1292
|
+
"This workflow host cannot run nested workflows.",
|
|
1293
|
+
true,
|
|
1294
|
+
)
|
|
1295
|
+
return
|
|
1296
|
+
}
|
|
1297
|
+
let source: WorkflowScriptSource
|
|
1298
|
+
try {
|
|
1299
|
+
source = await loadWorkflow(ref)
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
respond(
|
|
1302
|
+
callId,
|
|
1303
|
+
false,
|
|
1304
|
+
undefined,
|
|
1305
|
+
error instanceof Error ? error.message : String(error),
|
|
1306
|
+
)
|
|
1307
|
+
return
|
|
1308
|
+
}
|
|
1309
|
+
if (!source.ok) {
|
|
1310
|
+
respond(callId, false, undefined, source.message)
|
|
1311
|
+
return
|
|
1312
|
+
}
|
|
1313
|
+
try {
|
|
1314
|
+
const child = validateScript(source.script)
|
|
1315
|
+
respond(callId, true, {
|
|
1316
|
+
name: child.meta.name,
|
|
1317
|
+
metaJson: JSON.stringify(child.meta),
|
|
1318
|
+
body: child.body,
|
|
1319
|
+
})
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
respond(
|
|
1322
|
+
callId,
|
|
1323
|
+
false,
|
|
1324
|
+
undefined,
|
|
1325
|
+
error instanceof Error ? error.message : String(error),
|
|
1326
|
+
)
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
worker.on("message", (message: WorkerMessage) => {
|
|
1331
|
+
if (settled) return
|
|
1332
|
+
switch (message.type) {
|
|
1333
|
+
case "progress":
|
|
1334
|
+
emit(message.entries)
|
|
1335
|
+
break
|
|
1336
|
+
case "call":
|
|
1337
|
+
if (message.method === "workflow") {
|
|
1338
|
+
void handleLoadWorkflow(
|
|
1339
|
+
message.callId,
|
|
1340
|
+
message.payload as WorkflowScriptRef,
|
|
1341
|
+
)
|
|
1342
|
+
break
|
|
1343
|
+
}
|
|
1344
|
+
if (message.method !== "agent") {
|
|
1345
|
+
respond(
|
|
1346
|
+
message.callId,
|
|
1347
|
+
false,
|
|
1348
|
+
undefined,
|
|
1349
|
+
`Unknown workflow host method "${message.method}".`,
|
|
1350
|
+
true,
|
|
1351
|
+
)
|
|
1352
|
+
break
|
|
1353
|
+
}
|
|
1354
|
+
void handleAgent(message.callId, message.payload as AgentCallPayload)
|
|
1355
|
+
break
|
|
1356
|
+
case "complete": {
|
|
1357
|
+
// The script is done, so every launch it made should have been
|
|
1358
|
+
// answered by now — a response is sent before the worker can post
|
|
1359
|
+
// this, so anything still open was never awaited. finish() aborts
|
|
1360
|
+
// those children on the way out.
|
|
1361
|
+
const unawaited = [...openLaunches.values()]
|
|
1362
|
+
if (unawaited.length > 0) {
|
|
1363
|
+
finish({
|
|
1364
|
+
status: "failed",
|
|
1365
|
+
error: unawaitedLaunchMessage(unawaited),
|
|
1366
|
+
})
|
|
1367
|
+
break
|
|
1368
|
+
}
|
|
1369
|
+
finish({
|
|
1370
|
+
status: "completed",
|
|
1371
|
+
...(message.resultJson === undefined
|
|
1372
|
+
? {}
|
|
1373
|
+
: { value: JSON.parse(message.resultJson) }),
|
|
1374
|
+
})
|
|
1375
|
+
break
|
|
1376
|
+
}
|
|
1377
|
+
case "error":
|
|
1378
|
+
finish({ status: "failed", error: message.message })
|
|
1379
|
+
break
|
|
1380
|
+
}
|
|
1381
|
+
})
|
|
1382
|
+
|
|
1383
|
+
worker.on("error", (error) => {
|
|
1384
|
+
finish({
|
|
1385
|
+
status: "failed",
|
|
1386
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1387
|
+
})
|
|
1388
|
+
})
|
|
1389
|
+
|
|
1390
|
+
worker.on("exit", () => {
|
|
1391
|
+
// Only reachable when the worker dies without reporting — a terminate()
|
|
1392
|
+
// we did not initiate, or a hard crash.
|
|
1393
|
+
finish({
|
|
1394
|
+
status: "failed",
|
|
1395
|
+
error: "Workflow worker exited before completing.",
|
|
1396
|
+
})
|
|
1397
|
+
})
|
|
1398
|
+
})
|
|
1399
|
+
}
|