@herbertgao/pi-subagents 0.17.1 → 0.18.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 +6 -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 +11 -9
- 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,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host.ts — binds a workflow run to the real `AgentManager`.
|
|
3
|
+
*
|
|
4
|
+
* `runtime.ts` deliberately knows nothing about this extension: its only seam is
|
|
5
|
+
* the injected {@link WorkflowHost}, which is what keeps the runtime's tests
|
|
6
|
+
* free of sessions, models and git. This file is the other half of that seam —
|
|
7
|
+
* everything the script can reach through `agent()`, `resume` and `gate` ends up
|
|
8
|
+
* here, and nowhere else.
|
|
9
|
+
*
|
|
10
|
+
* Four mappings carry most of the weight:
|
|
11
|
+
*
|
|
12
|
+
* - **ids.** The runtime hands out its own `wf-agent-N` handles before
|
|
13
|
+
* anything spawns, because it needs a stable progress-entry identity. The
|
|
14
|
+
* manager issues a different id when the child actually starts. `records`
|
|
15
|
+
* is the translation, and it is kept for the whole run rather than cleared
|
|
16
|
+
* on completion: `resume` reaches back to a child that has already
|
|
17
|
+
* finished.
|
|
18
|
+
* - **agent type and model.** Resolved through `resolveSpawnType` and
|
|
19
|
+
* `getAgentConfig` — the same dispatch the `Agent` tool uses — so a
|
|
20
|
+
* workflow and a tool call disagree about nothing.
|
|
21
|
+
* - **failure.** A strict worktree-isolation failure throws out of
|
|
22
|
+
* `spawnAndWait`; the script must see that as an agent that failed
|
|
23
|
+
* (`{ok: false}` → `null`), not as an unhandled rejection that takes the
|
|
24
|
+
* run down.
|
|
25
|
+
* - **when a `gate` runs.** For an isolated child it cannot wait until the
|
|
26
|
+
* spawn resolves: the manager commits the worktree to a branch and deletes
|
|
27
|
+
* the copy inside the child's own settle, so by then the only tree left to
|
|
28
|
+
* run `npm test` in is the main one — which would report on code the child
|
|
29
|
+
* never wrote. So the gate runs from `onBeforeWorktreeCleanup`, inside that
|
|
30
|
+
* settle, and the verdict travels back on the spawn result. `runGate` still
|
|
31
|
+
* exists for a child that had no worktree; the runtime uses whichever of
|
|
32
|
+
* the two happened, never both.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { existsSync } from "node:fs"
|
|
36
|
+
import type {
|
|
37
|
+
ExtensionAPI,
|
|
38
|
+
ExtensionContext,
|
|
39
|
+
} from "@earendil-works/pi-coding-agent"
|
|
40
|
+
import type { AgentManager } from "../agent-manager.js"
|
|
41
|
+
import { getAgentConfig, resolveSpawnType } from "../agent-types.js"
|
|
42
|
+
import { resolveModel } from "../model-resolver.js"
|
|
43
|
+
import { checkModelScope } from "../model-scope.js"
|
|
44
|
+
import type { AgentRecord, ThinkingLevel } from "../types.js"
|
|
45
|
+
import { getLifetimeTotal } from "../usage.js"
|
|
46
|
+
import type {
|
|
47
|
+
WorkflowGateResult,
|
|
48
|
+
WorkflowHost,
|
|
49
|
+
WorkflowSpawnResult,
|
|
50
|
+
} from "./runtime.js"
|
|
51
|
+
import { resolveWorkflowSource } from "./saved.js"
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wall-clock bound on a `gate` command. Generous — a gate is routinely a test
|
|
55
|
+
* suite — but not unbounded: `pi.exec` reports a timeout as `killed`, and a
|
|
56
|
+
* gate that hangs forever would wedge the agent slot it is holding.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_GATE_TIMEOUT_MS = 10 * 60_000
|
|
59
|
+
|
|
60
|
+
export interface WorkflowHostOptions {
|
|
61
|
+
pi: ExtensionAPI
|
|
62
|
+
ctx: ExtensionContext
|
|
63
|
+
manager: AgentManager
|
|
64
|
+
/** The run's abort signal, so killing the workflow kills its children. */
|
|
65
|
+
signal?: AbortSignal
|
|
66
|
+
/** Groups child transcripts under the parent session. */
|
|
67
|
+
rootSessionId?: string
|
|
68
|
+
/**
|
|
69
|
+
* The run id every child is stamped with.
|
|
70
|
+
*
|
|
71
|
+
* What makes them the workflow's rather than the session's: stamped children
|
|
72
|
+
* are filtered out of the fleet list, the widget, the `/agents` menus and
|
|
73
|
+
* `@handle` resolution, and they take no `maxConcurrent` slot. The run
|
|
74
|
+
* reports for them, and it has its own concurrency cap.
|
|
75
|
+
*/
|
|
76
|
+
workflowId?: string
|
|
77
|
+
gateTimeoutMs?: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Where the child worked, when that directory still exists.
|
|
82
|
+
*
|
|
83
|
+
* The guard is not defensive padding. `cleanupWorktree` commits the child's
|
|
84
|
+
* changes to a branch and *removes* the copy before `spawnAndWait` resolves, so
|
|
85
|
+
* an isolated child's worktree is normally already gone by the time a result is
|
|
86
|
+
* built. That is exactly why a gate cannot wait until here — it runs from
|
|
87
|
+
* `onBeforeWorktreeCleanup` instead — and why this reports nothing rather than
|
|
88
|
+
* a path that no longer exists: handing a stale path to a command would fail
|
|
89
|
+
* every gated worktree agent with a spawn error instead of a test result.
|
|
90
|
+
*/
|
|
91
|
+
function childCwd(record: AgentRecord): string | undefined {
|
|
92
|
+
// `path`, not `workPath`: a workflow spawn never passes a cwd, so the manager
|
|
93
|
+
// runs the child at the copied repo's root.
|
|
94
|
+
const path = record.worktree?.path
|
|
95
|
+
return path !== undefined && existsSync(path) ? path : undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether the child itself succeeded — the same condition {@link toSpawnResult}
|
|
100
|
+
* turns into `ok`, read from the live record so the pre-cleanup hook can tell a
|
|
101
|
+
* finished child from a failed one before the result exists.
|
|
102
|
+
*/
|
|
103
|
+
function succeeded(record: AgentRecord | undefined): boolean {
|
|
104
|
+
return record?.status === "completed" || record?.status === "steered"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Translate a settled record into what the script sees. */
|
|
108
|
+
/**
|
|
109
|
+
* The effective-configuration half of a record, in the runtime's pi-free shape.
|
|
110
|
+
*
|
|
111
|
+
* `AgentRecord.invocation` is the one authoritative place for it (#168); this
|
|
112
|
+
* only renames the fields across the boundary. Returns undefined until the
|
|
113
|
+
* child's session has reported a model, which is when any of it is knowable.
|
|
114
|
+
*/
|
|
115
|
+
function resolvedInfo(record: AgentRecord | undefined) {
|
|
116
|
+
const invocation = record?.invocation
|
|
117
|
+
if (invocation?.modelName === undefined) return undefined
|
|
118
|
+
return {
|
|
119
|
+
modelName: invocation.modelName,
|
|
120
|
+
modelId: invocation.modelId,
|
|
121
|
+
thinking: invocation.thinking,
|
|
122
|
+
requestedThinking: invocation.requestedThinking,
|
|
123
|
+
requestedModel: invocation.requestedModel,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function toSpawnResult(record: AgentRecord): WorkflowSpawnResult {
|
|
128
|
+
const tokens = getLifetimeTotal(record.lifetimeUsage)
|
|
129
|
+
// Reported separately from `tokens`, which is the lifetime total. The script's
|
|
130
|
+
// `budget` counts *output* tokens, as Claude Code's does — billing the input
|
|
131
|
+
// and cache reads a fan-out re-sends would over-report it by an order of
|
|
132
|
+
// magnitude and make the documented guards useless.
|
|
133
|
+
const outputTokens = record.lifetimeUsage?.output ?? 0
|
|
134
|
+
const cwd = childCwd(record)
|
|
135
|
+
const common = {
|
|
136
|
+
...(tokens > 0 ? { tokens } : {}),
|
|
137
|
+
...(outputTokens > 0 ? { outputTokens } : {}),
|
|
138
|
+
...(record.toolUses > 0 ? { toolCalls: record.toolUses } : {}),
|
|
139
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (succeeded(record)) {
|
|
143
|
+
return {
|
|
144
|
+
...common,
|
|
145
|
+
ok: true,
|
|
146
|
+
// The schema'd payload when there is one: `result` is prose, and for a
|
|
147
|
+
// worktree child it has had the branch note appended, so it would not
|
|
148
|
+
// parse. A child asked for a schema that produced none never reaches
|
|
149
|
+
// here — `runAgent` reports that through `failure`.
|
|
150
|
+
text: record.structuredJson ?? record.result ?? "",
|
|
151
|
+
...(record.structuredRetried ? { structuredRetried: true } : {}),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// "stopped" is someone reaching in and stopping this child — /agents, the
|
|
155
|
+
// fleet list, a workflow abort. That is the same thing the workflows dialog's
|
|
156
|
+
// skip action means, so it renders as skipped rather than failed.
|
|
157
|
+
if (record.status === "stopped") {
|
|
158
|
+
return {
|
|
159
|
+
...common,
|
|
160
|
+
ok: false,
|
|
161
|
+
skipped: true,
|
|
162
|
+
error: record.error ?? "Stopped.",
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
...common,
|
|
167
|
+
ok: false,
|
|
168
|
+
error: record.error ?? `Agent ${record.status}.`,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Shell used to run a `gate` command, mirroring how a user would type it. */
|
|
173
|
+
const GATE_SHELL: readonly [string, string] =
|
|
174
|
+
process.platform === "win32" ? ["cmd", "/c"] : ["sh", "-c"]
|
|
175
|
+
|
|
176
|
+
export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
177
|
+
const { pi, ctx, manager } = deps
|
|
178
|
+
/** Runtime agent id → the manager record it spawned. Never pruned mid-run. */
|
|
179
|
+
const records = new Map<string, string>()
|
|
180
|
+
/**
|
|
181
|
+
* scopeModels warnings already toasted, so a fan-out that pins one
|
|
182
|
+
* out-of-scope agent file raises one notification rather than one per child.
|
|
183
|
+
* Kept for the whole run: the same message is the same warning at agent 200
|
|
184
|
+
* as it was at agent 1.
|
|
185
|
+
*/
|
|
186
|
+
const warnedScopeMessages = new Set<string>()
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Run a gate command in `cwd`. The only place a gate is executed — `runGate`
|
|
190
|
+
* and the pre-cleanup hook both come through here — so "gate passed" is
|
|
191
|
+
* decided from one behaviour, whichever route the command took.
|
|
192
|
+
*/
|
|
193
|
+
async function executeGate(
|
|
194
|
+
command: string,
|
|
195
|
+
cwd: string,
|
|
196
|
+
): Promise<WorkflowGateResult> {
|
|
197
|
+
const result = await pi.exec(GATE_SHELL[0], [GATE_SHELL[1], command], {
|
|
198
|
+
cwd,
|
|
199
|
+
timeout: deps.gateTimeoutMs ?? DEFAULT_GATE_TIMEOUT_MS,
|
|
200
|
+
...(deps.signal !== undefined ? { signal: deps.signal } : {}),
|
|
201
|
+
})
|
|
202
|
+
const output = [result.stdout, result.stderr]
|
|
203
|
+
.map((stream) => stream.trim())
|
|
204
|
+
.filter(Boolean)
|
|
205
|
+
.join("\n")
|
|
206
|
+
// `pi.exec` reports a timeout as `killed` with exit code 0, so the code
|
|
207
|
+
// alone would read a killed gate as a passing one.
|
|
208
|
+
if (result.killed) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
output: output || `Gate command timed out: ${command}`,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { ok: result.code === 0, output }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
async spawnAgent(request) {
|
|
219
|
+
const dispatch = resolveSpawnType(request.agentType)
|
|
220
|
+
if (!dispatch.ok) return { ok: false, error: dispatch.message }
|
|
221
|
+
|
|
222
|
+
// Same precedence as the Agent tool: the caller's model wins, the agent
|
|
223
|
+
// definition's is next, and the parent's is the floor. A model the script
|
|
224
|
+
// named and we cannot resolve is an error; one the definition named falls
|
|
225
|
+
// back to the parent silently, because the script never asked for it.
|
|
226
|
+
let model = ctx.model
|
|
227
|
+
const config = getAgentConfig(dispatch.type)
|
|
228
|
+
const modelInput = request.model ?? config?.model
|
|
229
|
+
if (modelInput !== undefined) {
|
|
230
|
+
const resolved = resolveModel(modelInput, ctx.modelRegistry)
|
|
231
|
+
if (typeof resolved === "string") {
|
|
232
|
+
if (request.model !== undefined) return { ok: false, error: resolved }
|
|
233
|
+
} else {
|
|
234
|
+
model = resolved
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Same scopeModels policy as the Agent tool and the nested delegation
|
|
239
|
+
// tools: a script's `agent({ model })` is a runtime LLM choice, and the
|
|
240
|
+
// script is written by the model, so it must not reach a model the user's
|
|
241
|
+
// enabledModels list excludes. `callerSupplied` keys off `request.model`
|
|
242
|
+
// and NOT `modelInput` — the latter has already absorbed the agent file's
|
|
243
|
+
// own `model:`, which is user-authored config and so earns the
|
|
244
|
+
// warn-and-proceed branch rather than a refusal.
|
|
245
|
+
const scopeVerdict = checkModelScope({
|
|
246
|
+
model,
|
|
247
|
+
cwd: ctx.cwd,
|
|
248
|
+
modelRegistry: ctx.modelRegistry,
|
|
249
|
+
callerSupplied: request.model !== undefined,
|
|
250
|
+
agentLabel: config?.displayName ?? dispatch.type,
|
|
251
|
+
modelInput,
|
|
252
|
+
})
|
|
253
|
+
// This agent's failure, not the run's — the same shape a bad agent type
|
|
254
|
+
// takes above. The script sees `null` and its siblings carry on, which is
|
|
255
|
+
// the difference between one refused model and a discarded fan-out.
|
|
256
|
+
if (scopeVerdict.kind === "error")
|
|
257
|
+
return { ok: false, error: scopeVerdict.message }
|
|
258
|
+
if (
|
|
259
|
+
scopeVerdict.kind === "warn" &&
|
|
260
|
+
!warnedScopeMessages.has(scopeVerdict.message)
|
|
261
|
+
) {
|
|
262
|
+
warnedScopeMessages.add(scopeVerdict.message)
|
|
263
|
+
ctx.ui.notify(scopeVerdict.message, "warning")
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The gate's verdict, set only if the hook below actually ran the command.
|
|
268
|
+
* Its presence is what stops the runtime running the gate a second time,
|
|
269
|
+
* so it is set on the failure route too — a gate we tried and could not
|
|
270
|
+
* complete is a failed gate, never an un-run one that then re-runs
|
|
271
|
+
* against the wrong tree.
|
|
272
|
+
*/
|
|
273
|
+
let gate: WorkflowGateResult | undefined
|
|
274
|
+
let spawnedId: string | undefined
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Hand the child's EFFECTIVE configuration back to the run.
|
|
278
|
+
*
|
|
279
|
+
* `AgentRecord.invocation` is the one authoritative place for it (#168),
|
|
280
|
+
* filled by the manager the moment the child's session exists — which is
|
|
281
|
+
* before this fires, so it is populated by the time we read it. Reading it
|
|
282
|
+
* rather than re-deriving "the session, else the request" is the whole
|
|
283
|
+
* point of that field existing.
|
|
284
|
+
*/
|
|
285
|
+
let sessionReady = false
|
|
286
|
+
const reportResolved = () => {
|
|
287
|
+
// Called from BOTH the session hook and the spawn hook, because their
|
|
288
|
+
// order is not guaranteed: the manager fires `onSpawned` after
|
|
289
|
+
// `runAgent` returns, but `runAgent` decides when its own
|
|
290
|
+
// `onSessionCreated` fires — synchronously, for a stub. Each fires once,
|
|
291
|
+
// so the first call finds a half missing and returns, and the second is
|
|
292
|
+
// the one that reports.
|
|
293
|
+
if (!sessionReady || spawnedId === undefined) return
|
|
294
|
+
const info = resolvedInfo(manager.getRecord(spawnedId))
|
|
295
|
+
if (info !== undefined) request.onResolved?.(info)
|
|
296
|
+
}
|
|
297
|
+
const command = request.gate
|
|
298
|
+
/**
|
|
299
|
+
* Verify the child's work while its worktree still exists.
|
|
300
|
+
*
|
|
301
|
+
* The manager destroys that copy inside the child's own settle, so this
|
|
302
|
+
* is the last (and only) moment at which `npm test` can mean "the code
|
|
303
|
+
* this child just wrote" rather than "whatever is in the main tree".
|
|
304
|
+
*/
|
|
305
|
+
const onBeforeWorktreeCleanup =
|
|
306
|
+
command === undefined
|
|
307
|
+
? undefined
|
|
308
|
+
: async (worktreePath: string): Promise<void> => {
|
|
309
|
+
// A failed child's gate is never consulted — the runtime reports
|
|
310
|
+
// the child's own failure — so running it would be pure cost.
|
|
311
|
+
if (
|
|
312
|
+
spawnedId === undefined ||
|
|
313
|
+
!succeeded(manager.getRecord(spawnedId))
|
|
314
|
+
)
|
|
315
|
+
return
|
|
316
|
+
try {
|
|
317
|
+
gate = await executeGate(command, worktreePath)
|
|
318
|
+
} catch (error) {
|
|
319
|
+
gate = {
|
|
320
|
+
ok: false,
|
|
321
|
+
output:
|
|
322
|
+
error instanceof Error ? error.message : String(error),
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const { record } = await manager.spawnAndWait(
|
|
329
|
+
pi,
|
|
330
|
+
ctx,
|
|
331
|
+
dispatch.type,
|
|
332
|
+
request.prompt,
|
|
333
|
+
{
|
|
334
|
+
description: request.label,
|
|
335
|
+
// The stamp is what keeps this child out of the session's
|
|
336
|
+
// `maxConcurrent` pool — see `occupiesPoolSlot`. The run already
|
|
337
|
+
// bounds how many of its agents run at once, and counting them
|
|
338
|
+
// twice would let one fan-out starve everything else the user is
|
|
339
|
+
// doing. No `bypassQueue` needed: an agent outside the pool is
|
|
340
|
+
// never queued behind it.
|
|
341
|
+
...(deps.workflowId !== undefined
|
|
342
|
+
? { workflowId: deps.workflowId }
|
|
343
|
+
: {}),
|
|
344
|
+
...(model !== undefined ? { model } : {}),
|
|
345
|
+
// Validated worker-side against the same list pi accepts, so the
|
|
346
|
+
// cast asserts what the boundary has already checked. Left unset,
|
|
347
|
+
// the agent definition's `thinking` (then the parent's) still wins —
|
|
348
|
+
// same precedence as `model` above.
|
|
349
|
+
...(request.effort !== undefined
|
|
350
|
+
? { thinkingLevel: request.effort as ThinkingLevel }
|
|
351
|
+
: {}),
|
|
352
|
+
// Seeded with the REQUEST, not the outcome. The manager overwrites
|
|
353
|
+
// the effective half at session creation; without a seed there is
|
|
354
|
+
// nothing for it to compare against, so a level pi clamped would be
|
|
355
|
+
// indistinguishable from one that was honoured.
|
|
356
|
+
//
|
|
357
|
+
// Only the level. #182's other half — a caller parameter an agent
|
|
358
|
+
// file outranked — cannot arise here: this path resolves
|
|
359
|
+
// `request.model ?? config?.model`, so the script always wins and
|
|
360
|
+
// therefore always got what it asked for. Seeding a `requestedModel`
|
|
361
|
+
// would describe a precedence this path does not have.
|
|
362
|
+
invocation: {
|
|
363
|
+
...(request.effort !== undefined
|
|
364
|
+
? { thinking: request.effort as ThinkingLevel }
|
|
365
|
+
: {}),
|
|
366
|
+
},
|
|
367
|
+
// Fires once the child's session exists, which is where the model
|
|
368
|
+
// and the clamped thinking level first become knowable.
|
|
369
|
+
onSessionCreated: () => {
|
|
370
|
+
sessionReady = true
|
|
371
|
+
reportResolved()
|
|
372
|
+
},
|
|
373
|
+
...(request.schema !== undefined
|
|
374
|
+
? { structuredOutput: request.schema }
|
|
375
|
+
: {}),
|
|
376
|
+
...(request.isolation !== undefined
|
|
377
|
+
? { isolation: request.isolation }
|
|
378
|
+
: {}),
|
|
379
|
+
...(deps.signal !== undefined ? { signal: deps.signal } : {}),
|
|
380
|
+
...(deps.rootSessionId !== undefined
|
|
381
|
+
? { rootSessionId: deps.rootSessionId }
|
|
382
|
+
: {}),
|
|
383
|
+
...(onBeforeWorktreeCleanup !== undefined
|
|
384
|
+
? { onBeforeWorktreeCleanup }
|
|
385
|
+
: {}),
|
|
386
|
+
},
|
|
387
|
+
(id) => {
|
|
388
|
+
spawnedId = id
|
|
389
|
+
records.set(request.agentId, id)
|
|
390
|
+
// Ahead of `reportResolved`, and not folded into it: that one waits
|
|
391
|
+
// for the child's session so it can name the effective model, while
|
|
392
|
+
// the record id is known here and is what the inspector opens a
|
|
393
|
+
// conversation on. A child that fails before its session resolves
|
|
394
|
+
// would otherwise never be openable at all.
|
|
395
|
+
request.onResolved?.({ recordId: id })
|
|
396
|
+
reportResolved()
|
|
397
|
+
},
|
|
398
|
+
)
|
|
399
|
+
return {
|
|
400
|
+
...toSpawnResult(record),
|
|
401
|
+
...(gate !== undefined ? { gate } : {}),
|
|
402
|
+
}
|
|
403
|
+
} catch (error) {
|
|
404
|
+
// Strict worktree isolation rejects out of `awaitStartup` — the child
|
|
405
|
+
// never ran. That is this agent's failure, not the run's: the script
|
|
406
|
+
// sees `null` and its siblings carry on.
|
|
407
|
+
return {
|
|
408
|
+
ok: false,
|
|
409
|
+
error: error instanceof Error ? error.message : String(error),
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
abortAgent(agentId) {
|
|
415
|
+
const id = records.get(agentId)
|
|
416
|
+
// Nothing to abort before the manager has issued an id — the child is
|
|
417
|
+
// still in startup, and the run's own signal reaches it there.
|
|
418
|
+
if (id !== undefined) manager.abort(id)
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
async resumeAgent(agentId, prompt, onResolved) {
|
|
422
|
+
const id = records.get(agentId)
|
|
423
|
+
if (id === undefined) {
|
|
424
|
+
return {
|
|
425
|
+
ok: false,
|
|
426
|
+
error: `Cannot resume "${agentId}" — it never started.`,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const record = await manager.resume(id, prompt, deps.signal)
|
|
430
|
+
if (record === undefined) {
|
|
431
|
+
return {
|
|
432
|
+
ok: false,
|
|
433
|
+
error: `Agent ${id} has no session left to resume — records are dropped ten minutes after they finish.`,
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// The resumed row is built from scratch, so it has to be told the same
|
|
437
|
+
// thing the first one was — the child's session already exists, so this is
|
|
438
|
+
// simply read back rather than waited for. The id goes first for the same
|
|
439
|
+
// reason it does on the spawn path: it is knowable even when the rest is
|
|
440
|
+
// not.
|
|
441
|
+
onResolved?.({ recordId: id })
|
|
442
|
+
const info = resolvedInfo(record)
|
|
443
|
+
if (info !== undefined) onResolved?.(info)
|
|
444
|
+
return toSpawnResult(record)
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Resolve a nested `workflow()` reference. The runtime decides whether what
|
|
449
|
+
* comes back is a workflow; this only finds it.
|
|
450
|
+
*/
|
|
451
|
+
loadWorkflow(ref) {
|
|
452
|
+
return resolveWorkflowSource(ref, ctx.cwd)
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
// Reached only for a gate the spawn did not already run — a child with no
|
|
456
|
+
// worktree of its own, or a host wired without the pre-cleanup hook.
|
|
457
|
+
async runGate(command, gate) {
|
|
458
|
+
// The child's worktree when it had one and it survived; otherwise the
|
|
459
|
+
// session's own directory, which is where a non-isolated child worked.
|
|
460
|
+
return await executeGate(command, gate.cwd ?? ctx.cwd)
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* journal.ts — the record a workflow run leaves so a later run can skip work.
|
|
3
|
+
*
|
|
4
|
+
* ## What resume actually buys
|
|
5
|
+
*
|
|
6
|
+
* The documented iteration loop is "edit the persisted script and re-run it".
|
|
7
|
+
* Without a journal that re-pays every agent from scratch, which for a 40-agent
|
|
8
|
+
* audit is the entire cost of the run — to change one line of the last stage.
|
|
9
|
+
* With one, the unchanged prefix comes back from disk and only the edit runs.
|
|
10
|
+
*
|
|
11
|
+
* ## Why a *prefix*, and not a lookup table
|
|
12
|
+
*
|
|
13
|
+
* Each entry is keyed by both its position in the run and a hash of everything
|
|
14
|
+
* that decides what that agent does. A replay walks positions in order and
|
|
15
|
+
* stops reusing at the first entry that does not match — every call from there
|
|
16
|
+
* on runs live. Reusing later matches out of order would be reusing a result
|
|
17
|
+
* produced under different upstream conditions: the same prompt at position 12
|
|
18
|
+
* of a *different* run is not the same work, because what fed it changed.
|
|
19
|
+
*
|
|
20
|
+
* A failed agent is journaled as a failure and never replayed as one. Resuming
|
|
21
|
+
* a run that died at agent 5 exists to retry agent 5, so the prefix ends there
|
|
22
|
+
* and 5 onwards run live — the alternative would make a failure permanent.
|
|
23
|
+
*
|
|
24
|
+
* ## Runs that use `agent({ resume })`
|
|
25
|
+
*
|
|
26
|
+
* Those are not replayed at all. A replayed agent is text from a file, not a
|
|
27
|
+
* live child, so there is no conversation in this run for a later `resume` to
|
|
28
|
+
* continue — and the id map that would find one belongs to the run that did
|
|
29
|
+
* the spawning. Rather than replay a prefix that strands the first `resume`
|
|
30
|
+
* call, a journal carrying one declines the whole cache and the run pays in
|
|
31
|
+
* full. Coarse on purpose: the alternative is tracking which label each entry
|
|
32
|
+
* ran under and capping the prefix below the earliest one that gets resumed,
|
|
33
|
+
* which is a second key concept for a case that costs one run.
|
|
34
|
+
*
|
|
35
|
+
* ## Ordering under concurrency
|
|
36
|
+
*
|
|
37
|
+
* Positions are assigned as calls arrive, and with `pipeline` that order
|
|
38
|
+
* depends on which agent finished first. A replay usually reproduces it, since
|
|
39
|
+
* cached calls answer in journal order, but it is not guaranteed. That is why
|
|
40
|
+
* the key is checked as well as the position: a run that interleaves
|
|
41
|
+
* differently loses cache hits, it never returns another agent's answer.
|
|
42
|
+
*
|
|
43
|
+
* The file is JSON Lines, appended as each agent settles, so a run that is
|
|
44
|
+
* killed mid-flight still leaves everything it had finished.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { createHash } from "node:crypto"
|
|
48
|
+
import { appendFileSync, readFileSync } from "node:fs"
|
|
49
|
+
|
|
50
|
+
/** One settled agent call, as replayed. */
|
|
51
|
+
export interface WorkflowJournalEntry {
|
|
52
|
+
/** Position in the run — the same counter that names `wf-agent-N`. */
|
|
53
|
+
index: number
|
|
54
|
+
/** Hash of the call's payload; a mismatch ends the replayable prefix. */
|
|
55
|
+
key: string
|
|
56
|
+
/** Whether the agent succeeded. A failure ends the prefix on replay. */
|
|
57
|
+
ok: boolean
|
|
58
|
+
/** The agent's answer, when it had one. */
|
|
59
|
+
text?: string
|
|
60
|
+
/**
|
|
61
|
+
* Whether the call continued an earlier child (`agent({ resume })`).
|
|
62
|
+
*
|
|
63
|
+
* A replayed agent leaves no session behind in the run that replays it — the
|
|
64
|
+
* conversation belongs to the run that actually spawned it, and the host's
|
|
65
|
+
* id map is per-run — so a later `resume` would have nothing to continue.
|
|
66
|
+
* Recording it lets the next run decline to replay at all rather than fail
|
|
67
|
+
* partway through, which is why the flag is on the journal and not derived.
|
|
68
|
+
*/
|
|
69
|
+
resumed?: true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The fields that decide what an agent does.
|
|
74
|
+
*
|
|
75
|
+
* Deliberately not the whole payload: `phaseIndex` and `phaseTitle` move the
|
|
76
|
+
* row around in the progress tree without changing a single token the agent
|
|
77
|
+
* sees, so re-grouping phases should not throw away an hour of results.
|
|
78
|
+
*/
|
|
79
|
+
export interface JournalKeyInput {
|
|
80
|
+
prompt: string
|
|
81
|
+
label?: string
|
|
82
|
+
model?: string
|
|
83
|
+
agentType?: string
|
|
84
|
+
effort?: string
|
|
85
|
+
isolation?: string
|
|
86
|
+
gate?: string
|
|
87
|
+
resume?: string
|
|
88
|
+
/** Serialized `agent({ schema })`, when the call asked for one. */
|
|
89
|
+
schema?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Stable hash of a call's payload. Field order is fixed here, not by the caller. */
|
|
93
|
+
export function journalKey(input: JournalKeyInput): string {
|
|
94
|
+
const canonical = JSON.stringify([
|
|
95
|
+
input.prompt,
|
|
96
|
+
input.label ?? null,
|
|
97
|
+
input.model ?? null,
|
|
98
|
+
input.agentType ?? null,
|
|
99
|
+
input.effort ?? null,
|
|
100
|
+
input.isolation ?? null,
|
|
101
|
+
input.gate ?? null,
|
|
102
|
+
input.resume ?? null,
|
|
103
|
+
// Appended only when present, which looks like a hack and is not: adding a
|
|
104
|
+
// ninth slot unconditionally would change the canonical form of every entry
|
|
105
|
+
// and invalidate every journal already on disk. Conditional, a schema-less
|
|
106
|
+
// call keys exactly as it always did, and adding or changing a schema still
|
|
107
|
+
// produces a different key.
|
|
108
|
+
...(input.schema !== undefined ? [input.schema] : []),
|
|
109
|
+
])
|
|
110
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 32)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read a journal file into position order.
|
|
115
|
+
*
|
|
116
|
+
* Never throws: a missing, truncated or hand-mangled journal means "nothing to
|
|
117
|
+
* replay", which costs tokens. Refusing to run would cost the whole run.
|
|
118
|
+
* A partial last line is normal — the file is appended to while agents settle.
|
|
119
|
+
*/
|
|
120
|
+
export function readJournal(path: string): WorkflowJournalEntry[] {
|
|
121
|
+
let raw: string
|
|
122
|
+
try {
|
|
123
|
+
raw = readFileSync(path, "utf-8")
|
|
124
|
+
} catch {
|
|
125
|
+
return []
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const entries: WorkflowJournalEntry[] = []
|
|
129
|
+
for (const line of raw.split("\n")) {
|
|
130
|
+
if (line.trim() === "") continue
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(line) as unknown
|
|
133
|
+
if (!isEntry(parsed)) continue
|
|
134
|
+
entries.push(parsed)
|
|
135
|
+
} catch {
|
|
136
|
+
// A half-written final line, or someone editing the file. Skipping it
|
|
137
|
+
// keeps what came before, and a shorter prefix is still a useful one.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
entries.sort((a, b) => a.index - b.index)
|
|
141
|
+
return entries
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Append one settled call. Failure to write is not failure to run. */
|
|
145
|
+
export function appendJournal(path: string, entry: WorkflowJournalEntry): void {
|
|
146
|
+
try {
|
|
147
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf-8")
|
|
148
|
+
} catch {
|
|
149
|
+
// A journal that cannot be written costs a future resume, nothing more.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isEntry(value: unknown): value is WorkflowJournalEntry {
|
|
154
|
+
if (typeof value !== "object" || value === null) return false
|
|
155
|
+
const entry = value as Record<string, unknown>
|
|
156
|
+
return (
|
|
157
|
+
Number.isInteger(entry.index) &&
|
|
158
|
+
(entry.index as number) >= 0 &&
|
|
159
|
+
typeof entry.key === "string" &&
|
|
160
|
+
typeof entry.ok === "boolean" &&
|
|
161
|
+
(entry.text === undefined || typeof entry.text === "string") &&
|
|
162
|
+
(entry.resumed === undefined || entry.resumed === true)
|
|
163
|
+
)
|
|
164
|
+
}
|