@getpipher/armory-fleet 0.11.0 → 0.12.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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/engine/concurrency-lock.ts +20 -15
  3. package/src/engine/spawnSubagent.ts +8 -2
  4. package/src/index.ts +112 -8
  5. package/src/panel/fleet-panel.ts +165 -11
  6. package/src/runtime/async-runner.ts +79 -25
  7. package/src/runtime/reconcile.ts +12 -9
  8. package/src/runtime/resume.ts +21 -15
  9. package/src/runtime/run-journal.ts +2 -2
  10. package/src/scheduling/scheduler.ts +4 -0
  11. package/src/tools/fleet.ts +179 -0
  12. package/src/tools/subagent.ts +8 -2
  13. package/src/workflows/builtin/adversarial-review.js +19 -0
  14. package/src/workflows/builtin/code-review.js +13 -0
  15. package/src/workflows/builtin/codebase-audit.js +16 -0
  16. package/src/workflows/builtin/deep-research.js +12 -0
  17. package/src/workflows/builtin/multi-perspective.js +17 -0
  18. package/src/workflows/helpers/checkpoint.ts +15 -0
  19. package/src/workflows/helpers/completeness-check.ts +18 -0
  20. package/src/workflows/helpers/gate.ts +22 -0
  21. package/src/workflows/helpers/index.ts +8 -0
  22. package/src/workflows/helpers/judge-panel.ts +33 -0
  23. package/src/workflows/helpers/loop-until-dry.ts +21 -0
  24. package/src/workflows/helpers/retry.ts +17 -0
  25. package/src/workflows/helpers/types.ts +19 -0
  26. package/src/workflows/helpers/verify.ts +27 -0
  27. package/src/workflows/journal.ts +76 -0
  28. package/src/workflows/keyword.ts +22 -0
  29. package/src/workflows/panel/workflows-items.ts +150 -0
  30. package/src/workflows/panel/workflows-rows.ts +3 -0
  31. package/src/workflows/panel-host.ts +179 -0
  32. package/src/workflows/registry.ts +68 -0
  33. package/src/workflows/runner.ts +507 -0
  34. package/src/workflows/runtime/adapters.ts +182 -0
  35. package/src/workflows/runtime/controller.ts +493 -0
  36. package/src/workflows/runtime/hydrate.ts +116 -0
  37. package/src/workflows/runtime/pause-gate.ts +41 -0
  38. package/src/workflows/runtime/run-store.ts +31 -0
  39. package/src/workflows/runtime/save.ts +111 -0
  40. package/src/workflows/runtime/types.ts +78 -0
  41. package/src/workflows/source.ts +156 -0
  42. package/src/workflows/vm-realm.ts +106 -0
  43. package/src/worktree/worktree-service.ts +10 -0
@@ -0,0 +1,507 @@
1
+ // SPEC-6-3 — the workflow runner. Compiles a JS script in the vm realm, journals every
2
+ // agent()/helper()/checkpoint() call by positional call index, spawns child agents via deps.spawn,
3
+ // and returns the script's synthesized result. Resume (Task 6) + isolation/lifecycle (Task 7)
4
+ // + schema/budget (Task 8) layer on top of this core.
5
+ import { buildRealm, compileWorkflowScript, type RealmDeps } from "./vm-realm.ts";
6
+ import type { WorkflowJournal } from "./journal.ts";
7
+ import type { WorkflowProgressEvent, WorkflowRunState } from "./runtime/types.ts";
8
+ import * as helpers from "./helpers/index.ts";
9
+
10
+ /** Minimal JSON-Schema-ish validator: checks type, required, properties.<name>.type.
11
+ * Accepts a string (JSON.parse) or an object. Returns boolean. */
12
+ function validateResult(value: unknown, schema: Record<string, unknown>): boolean {
13
+ if (typeof value === "string") { try { value = JSON.parse(value); } catch { return false; } }
14
+ if (schema.type === "array" && Array.isArray(value)) return true;
15
+ if (schema.type === "null" && value === null) return true;
16
+ if (schema.type && typeof value !== schema.type) return false;
17
+ if (Array.isArray(schema.required)) {
18
+ for (const k of schema.required as string[])
19
+ if (!Object.prototype.hasOwnProperty.call(value, k)) return false;
20
+ }
21
+ const props = schema.properties as Record<string, { type: string }> | undefined;
22
+ if (props) {
23
+ for (const [k, def] of Object.entries(props))
24
+ if (k in (value as Record<string, unknown>) && typeof (value as Record<string, unknown>)[k] !== def.type) return false;
25
+ }
26
+ return true;
27
+ }
28
+
29
+ export interface WorkflowRuntimeHooks {
30
+ signal: AbortSignal;
31
+ waitIfPaused(): Promise<void>;
32
+ onProgress(event: WorkflowProgressEvent): void;
33
+ }
34
+
35
+ export interface WorkflowRunDeps {
36
+ spawn: (prompt: string, opts: { agent: string; model?: string; tier?: string; lifecycle?: string; isolation?: "worktree"; skills?: string[]; backend?: "pi" | "claude"; timeoutMs?: number; runId: string }) => Promise<{ finalText: string; runId: string; status: "completed" | "failed"; costTotal?: number; tokenTotal?: number }>;
37
+ worktree: { isGitRepo(dir?: string): boolean; create(runId: string, baseRef?: string): { path: string; branch: string }; removeWorktree(runId: string): void; remove(runId: string): void };
38
+ tierRegistry: { get(name: string): { models: string[]; costCap?: number; contextFloor?: number } | undefined };
39
+ journal: WorkflowJournal;
40
+ runRegistry: { get(runId: string): { todoId?: string | null } | undefined; list(): { costTotal?: number; tokenTotal?: number; todoId?: string | null }[] };
41
+ getModelContextWindow?: (model: string) => number | undefined;
42
+ genRunId: () => string;
43
+ notify: (msg: string, level?: "info" | "warning" | "error") => void;
44
+ onCheckpoint?: (prompt: string, opts: Record<string, unknown>) => Promise<unknown>;
45
+ resolveWorkflow: (name: string) => { sourceText: string; executable: string } | undefined;
46
+ maxRecursionDepth?: number;
47
+ runLifecycle?: (task: string, name: string, opts: { mode: "auto" | "checkpointed"; worktreePath?: string }) => Promise<{ status: "completed" | "failed" | "aborted"; finalText: string; costTotal?: number; tokenTotal?: number; error?: string }>;
48
+ runtime?: WorkflowRuntimeHooks;
49
+ }
50
+
51
+ export interface WorkflowRunOpts {
52
+ sourceText?: string;
53
+ script: string;
54
+ args?: unknown;
55
+ runId?: string;
56
+ resumeFromRunId?: string;
57
+ mode: "auto" | "checkpointed";
58
+ budget?: { total: number };
59
+ maxAgents?: number;
60
+ concurrency?: number;
61
+ agentRetries?: number;
62
+ agentTimeoutMs?: number;
63
+ maxRecursionDepth?: number;
64
+ }
65
+
66
+ export interface WorkflowRunResult {
67
+ runId: string;
68
+ status: "completed" | "failed" | "aborted";
69
+ result?: unknown;
70
+ error?: string;
71
+ costTotal?: number;
72
+ tokenTotal?: number;
73
+ phases: { title: string; agents: number; cached: number; reRun: number }[];
74
+ logs: string[];
75
+ childRunIds: string[];
76
+ }
77
+
78
+ interface AgentCacheEntry { prompt: string; opts: Record<string, unknown>; result: unknown; status: string }
79
+ interface CheckpointCacheEntry { prompt: string; optsHash: string; response: unknown }
80
+
81
+ /** Run a workflow script. Resume (Task 6) is built in; isolation/lifecycle/schema land in Tasks 7-8. */
82
+ export async function runWorkflow(_ignored: string, opts: WorkflowRunOpts, deps: WorkflowRunDeps): Promise<WorkflowRunResult> {
83
+ const runId = opts.runId ?? deps.genRunId();
84
+ const maxAgents = opts.maxAgents ?? 1000;
85
+ const concurrency = Math.min(opts.concurrency ?? 3, 16);
86
+ const maxRecursion = opts.maxRecursionDepth ?? deps.maxRecursionDepth ?? 3;
87
+ const budgetTotal = opts.budget?.total ?? Number.POSITIVE_INFINITY;
88
+
89
+ // Resume: build the agent + checkpoint caches from the prior run's journal.
90
+ const agentCache = new Map<number, AgentCacheEntry>();
91
+ const checkpointCache = new Map<number, CheckpointCacheEntry>();
92
+ if (opts.resumeFromRunId) {
93
+ for (const e of deps.journal.replay(opts.resumeFromRunId)) {
94
+ if (e.type === "agent:call") agentCache.set(e.callIndex, { prompt: e.prompt, opts: e.opts, result: undefined as unknown, status: "pending" });
95
+ else if (e.type === "agent:result") {
96
+ const c = agentCache.get(e.callIndex);
97
+ if (c) { c.result = e.result; c.status = e.status; }
98
+ } else if (e.type === "helper:call" && (e as { name: string }).name === "checkpoint") {
99
+ // Build checkpoint cache from helper:call (has prompt + opts in args) — the checkpoint
100
+ // event (added later) fills in the response.
101
+ const args = e.args as [string, Record<string, unknown>];
102
+ checkpointCache.set(e.callIndex, { prompt: args[0] ?? "", optsHash: JSON.stringify(args[1] ?? {}), response: undefined as unknown });
103
+ } else if (e.type === "checkpoint") {
104
+ const c = checkpointCache.get(e.callIndex);
105
+ if (c) c.response = e.response;
106
+ }
107
+ }
108
+ }
109
+
110
+ let callIndex = 0;
111
+ const phaseCounts = new Map<string, { agents: number; cached: number; reRun: number }>();
112
+ let currentPhase = "default";
113
+ let agentCount = 0;
114
+ let spent = 0;
115
+ const logs: string[] = [];
116
+ const childRunIds: string[] = [];
117
+ let costAccum = 0;
118
+ const startedAt = Date.now();
119
+ let terminalWritten = false;
120
+
121
+ const nextCallIndex = () => callIndex++;
122
+
123
+ const safeSerialize = (value: unknown): string => {
124
+ try { return JSON.stringify(value) ?? String(value); }
125
+ catch { return String(value); }
126
+ };
127
+
128
+ const emitProgress = (kind: WorkflowProgressEvent["kind"]): void => {
129
+ const phasesSnapshot = [...phaseCounts.entries()].map(([title, c]) => ({ title, ...c }));
130
+ const status = kind === "completed" ? "completed" : kind === "aborted" ? "aborted" : kind === "failed" ? "failed" : "running";
131
+ const snapshot: WorkflowRunState = {
132
+ runId,
133
+ name: _ignored,
134
+ script: opts.sourceText ?? opts.script,
135
+ ...(opts.args !== undefined ? { args: opts.args } : {}),
136
+ mode: opts.mode,
137
+ status: status as WorkflowRunState["status"],
138
+ startedAt,
139
+ currentPhase,
140
+ phases: phasesSnapshot,
141
+ childRunIds: [...childRunIds],
142
+ logs: [...logs],
143
+ tokenTotal: spent,
144
+ costTotal: costAccum,
145
+ };
146
+ if (deps.runtime) deps.runtime.onProgress({ kind, runId, snapshot });
147
+ deps.journal.append(runId, {
148
+ type: "wf:progress",
149
+ kind,
150
+ runId,
151
+ status,
152
+ currentPhase,
153
+ phases: phasesSnapshot,
154
+ childRunIds: [...childRunIds],
155
+ logs: [...logs],
156
+ tokenTotal: spent,
157
+ costTotal: costAccum,
158
+ ts: Date.now(),
159
+ });
160
+ };
161
+
162
+ const beforeDispatch = async (): Promise<void> => {
163
+ await deps.runtime?.waitIfPaused();
164
+ if (deps.runtime?.signal.aborted) {
165
+ throw deps.runtime.signal.reason instanceof Error
166
+ ? deps.runtime.signal.reason
167
+ : new Error("workflow stopped");
168
+ }
169
+ };
170
+
171
+ const log = (message: unknown): void => {
172
+ const line = (typeof message === "string" ? message : safeSerialize(message)).slice(0, 500);
173
+ logs.push(line);
174
+ if (logs.length > 100) logs.shift();
175
+ emitProgress("log");
176
+ };
177
+
178
+ type TrackedSpawnOpts = {
179
+ agent: string;
180
+ model?: string;
181
+ tier?: string;
182
+ skills?: string[];
183
+ backend?: "pi" | "claude";
184
+ isolation?: "worktree";
185
+ retries?: number;
186
+ timeoutMs?: number;
187
+ runId: string;
188
+ };
189
+
190
+ const trackedSpawn = async (prompt: string, spawnOpts: TrackedSpawnOpts): Promise<{ finalText: string; runId: string; status: "completed" | "failed"; costTotal?: number; tokenTotal?: number }> => {
191
+ await beforeDispatch();
192
+ if (spent >= budgetTotal) throw new Error("token budget exceeded");
193
+ const effectiveTimeout = spawnOpts.timeoutMs ?? opts.agentTimeoutMs;
194
+ let result: { finalText: string; runId: string; status: "completed" | "failed"; costTotal?: number; tokenTotal?: number };
195
+ if (effectiveTimeout && effectiveTimeout > 0) {
196
+ const timeoutSignal = AbortSignal.timeout(effectiveTimeout);
197
+ try {
198
+ result = await Promise.race([
199
+ deps.spawn(prompt, spawnOpts),
200
+ new Promise<never>((_, reject) => {
201
+ timeoutSignal.addEventListener("abort", () => reject(new Error(`agent timed out after ${effectiveTimeout}ms`)), { once: true });
202
+ }),
203
+ ]);
204
+ } catch (e) {
205
+ // A workflow abort wins over a spawn timeout.
206
+ if (deps.runtime?.signal.aborted) {
207
+ throw deps.runtime.signal.reason instanceof Error ? deps.runtime.signal.reason : new Error("workflow stopped");
208
+ }
209
+ // Treat a timeout as a retryable failed spawn.
210
+ result = { finalText: "", runId: spawnOpts.runId, status: "failed" };
211
+ }
212
+ } else {
213
+ result = await deps.spawn(prompt, spawnOpts);
214
+ }
215
+ childRunIds.push(result.runId);
216
+ spent += result.tokenTotal ?? 0;
217
+ costAccum += result.costTotal ?? 0;
218
+ emitProgress(result.status === "completed" ? "child-completed" : "child-failed");
219
+ return result;
220
+ };
221
+ const phaseOf = (title: string, _opts?: { budget?: number }): void => {
222
+ currentPhase = title;
223
+ if (!phaseCounts.has(title)) phaseCounts.set(title, { agents: 0, cached: 0, reRun: 0 });
224
+ emitProgress("phase");
225
+ };
226
+
227
+ // The agent() global — spawns a child, journals call+result by index. On resume, reuses
228
+ // cached result when prompt + opts match the prior run's call at the same index.
229
+ const agent = async (prompt: string, callOpts: Record<string, unknown> = {}): Promise<unknown> => {
230
+ if (agentCount >= maxAgents) throw new Error(`max agents (${maxAgents}) exceeded`);
231
+ const remaining = budgetTotal - spent;
232
+ if (remaining <= 0) throw new Error("token budget exceeded");
233
+ const idx = nextCallIndex();
234
+ const label = (callOpts.label as string) ?? `agent ${idx}`;
235
+ const phase = (callOpts.phase as string) ?? currentPhase;
236
+ deps.journal.append(runId, { type: "agent:call", callIndex: idx, label, phase, prompt, opts: callOpts, ts: Date.now() });
237
+ const pc = phaseCounts.get(phase) ?? { agents: 0, cached: 0, reRun: 0 };
238
+ pc.agents++;
239
+
240
+ // Resume: reuse cached result when prompt + opts match the prior run at this index.
241
+ const cached = agentCache.get(idx);
242
+ if (cached && cached.status !== "pending" && cached.prompt === prompt && JSON.stringify(cached.opts) === JSON.stringify(callOpts)) {
243
+ pc.cached++;
244
+ phaseCounts.set(phase, pc);
245
+ deps.journal.append(runId, { type: "agent:result", callIndex: idx, childRunId: "(cached)", result: cached.result, status: cached.status as "completed" | "failed", ts: Date.now() });
246
+ return cached.result;
247
+ }
248
+
249
+ pc.reRun++;
250
+ phaseCounts.set(phase, pc);
251
+ agentCount++;
252
+
253
+ // Lifecycle bridge (the moat) — runs a full superpowers lifecycle as this one step.
254
+ // Ordered FIRST so agent({lifecycle, isolation:'worktree'}) runs the lifecycle in the worktree.
255
+ if (callOpts.lifecycle) {
256
+ if (!deps.runLifecycle) throw new Error("lifecycle bridge not configured");
257
+ await beforeDispatch();
258
+ const lcName = callOpts.lifecycle as string;
259
+ let worktreePath: string | undefined;
260
+ let wtRunId: string | undefined;
261
+ if (callOpts.isolation === "worktree") {
262
+ if (!deps.worktree.isGitRepo()) {
263
+ // Deterministic env error → null (not retryable). Journal result for completeness.
264
+ deps.journal.append(runId, { type: "agent:result", callIndex: idx, childRunId: "(fail-fast)", result: null, status: "failed", ts: Date.now() });
265
+ return null;
266
+ }
267
+ wtRunId = deps.genRunId();
268
+ worktreePath = deps.worktree.create(wtRunId).path;
269
+ }
270
+ try {
271
+ const lcRes = await deps.runLifecycle(prompt, lcName, { mode: opts.mode, ...(worktreePath ? { worktreePath } : {}) });
272
+ spent += lcRes.tokenTotal ?? 0;
273
+ costAccum += lcRes.costTotal ?? 0;
274
+ if (wtRunId) childRunIds.push(wtRunId);
275
+ emitProgress(lcRes.status === "completed" ? "child-completed" : "child-failed");
276
+ deps.journal.append(runId, { type: "agent:result", callIndex: idx, childRunId: wtRunId ?? "(lifecycle)", result: lcRes.status === "completed" ? lcRes.finalText : null, status: lcRes.status === "completed" ? "completed" : "failed", ...(lcRes.costTotal != null ? { costTotal: lcRes.costTotal } : {}), ...(lcRes.tokenTotal != null ? { tokenTotal: lcRes.tokenTotal } : {}), ts: Date.now() });
277
+ return lcRes.status === "completed" ? lcRes.finalText : null;
278
+ } finally { if (wtRunId) deps.worktree.removeWorktree(wtRunId); }
279
+ }
280
+
281
+ // Isolation: 'worktree' (no lifecycle) — v0.11.1 seam fail-fast.
282
+ if (callOpts.isolation === "worktree") {
283
+ if (!deps.worktree.isGitRepo()) {
284
+ // Deterministic env error → null (not retryable). Journal result for completeness.
285
+ deps.journal.append(runId, { type: "agent:result", callIndex: idx, childRunId: "(fail-fast)", result: null, status: "failed", ts: Date.now() });
286
+ return null;
287
+ }
288
+ const wtRunId = deps.genRunId();
289
+ deps.worktree.create(wtRunId);
290
+ try {
291
+ const res = await trackedSpawn(prompt, { agent: (callOpts.agentType as string) ?? "general-purpose", ...(callOpts.model ? { model: callOpts.model as string } : {}), ...(callOpts.tier ? { tier: callOpts.tier as string } : {}), ...(callOpts.timeoutMs ? { timeoutMs: callOpts.timeoutMs as number } : {}), isolation: "worktree", runId: wtRunId });
292
+ deps.journal.append(runId, { type: "agent:result", callIndex: idx, childRunId: res.runId, result: res.status === "completed" ? res.finalText : null, status: res.status, ...(res.costTotal != null ? { costTotal: res.costTotal } : {}), ts: Date.now() });
293
+ return res.status === "completed" ? res.finalText : null;
294
+ } finally { deps.worktree.removeWorktree(wtRunId); }
295
+ }
296
+
297
+ // Default in-place spawn (with optional schema validation via retries).
298
+ // Schema is ignored when lifecycle or isolation is set (those branches return above).
299
+ const maxRetries = (callOpts.retries as number | undefined) ?? opts.agentRetries ?? 0;
300
+ let attempt = 0;
301
+ let res: { finalText: string; runId: string; status: "completed" | "failed"; costTotal?: number; tokenTotal?: number };
302
+ let resultValue: unknown;
303
+ do {
304
+ res = await trackedSpawn(prompt, {
305
+ agent: (callOpts.agentType as string) ?? "general-purpose",
306
+ ...(callOpts.model ? { model: callOpts.model as string } : {}),
307
+ ...(callOpts.tier ? { tier: callOpts.tier as string } : {}),
308
+ ...(callOpts.skills ? { skills: Array.from(callOpts.skills as unknown[]) as string[] } : {}),
309
+ ...(callOpts.backend ? { backend: callOpts.backend as "pi" | "claude" } : {}),
310
+ ...(callOpts.timeoutMs ? { timeoutMs: callOpts.timeoutMs as number } : {}),
311
+ runId,
312
+ });
313
+ resultValue = res.status === "completed" ? res.finalText : null;
314
+ // Retry on failed spawn status when retries remain.
315
+ if (res.status !== "completed" && attempt < maxRetries) {
316
+ attempt++;
317
+ if (deps.runtime?.signal.aborted) break;
318
+ continue;
319
+ }
320
+ // Schema validation: if set + result non-null + mismatch -> re-spawn (one repair per retry).
321
+ if (callOpts.schema && resultValue != null && !validateResult(resultValue, callOpts.schema as Record<string, unknown>)) {
322
+ attempt++;
323
+ resultValue = null;
324
+ if (deps.runtime?.signal.aborted) break;
325
+ continue;
326
+ }
327
+ break;
328
+ } while (attempt <= maxRetries);
329
+ // Parse JSON ONLY when schema is present; non-schema agent returns the raw string.
330
+ if (callOpts.schema && resultValue != null && typeof resultValue === "string") {
331
+ try { resultValue = JSON.parse(resultValue); } catch { /* leave as string if not JSON */ }
332
+ }
333
+ if (!callOpts.schema && res.status !== "completed") resultValue = null;
334
+ deps.journal.append(runId, { type: "agent:result", callIndex: idx, childRunId: res.runId, result: resultValue, status: res.status, ...(res.costTotal != null ? { costTotal: res.costTotal } : {}), ...(res.tokenTotal != null ? { tokenTotal: res.tokenTotal } : {}), ts: Date.now() });
335
+ return resultValue;
336
+ };
337
+
338
+ // parallel() — concurrency-clamped, order-preserving.
339
+ const parallel = async (thunks: Array<() => Promise<unknown>>): Promise<unknown[]> => {
340
+ const results: unknown[] = new Array(thunks.length);
341
+ let next = 0;
342
+ const workers: Promise<void>[] = [];
343
+ for (let w = 0; w < concurrency; w++) workers.push((async () => {
344
+ while (true) {
345
+ const i = next++;
346
+ if (i >= thunks.length) break;
347
+ results[i] = await thunks[i]!();
348
+ }
349
+ })());
350
+ await Promise.all(workers);
351
+ return results;
352
+ };
353
+
354
+ // pipeline() — fan items through sequential stages.
355
+ const pipeline = async (items: unknown[], ...stages: Array<(item: unknown) => Promise<unknown>>): Promise<unknown[]> => {
356
+ let cur = items;
357
+ for (const stage of stages) cur = await Promise.all(cur.map((i) => stage(i)));
358
+ return cur;
359
+ };
360
+
361
+ // workflow() — run a saved workflow as a child. Recursion cap: throw if depth exhausted.
362
+ const workflow = async (name: string, childArgs?: unknown): Promise<unknown> => {
363
+ await beforeDispatch();
364
+ const resolved = deps.resolveWorkflow(name);
365
+ if (!resolved) throw new Error(`workflow '${name}' not found`);
366
+ if (maxRecursion - 1 < 0) throw new Error("workflow recursion depth exceeded");
367
+ emitProgress("child-started");
368
+ const childResult = await runWorkflow("child", {
369
+ script: resolved.executable,
370
+ sourceText: resolved.sourceText,
371
+ args: childArgs,
372
+ mode: opts.mode,
373
+ runId: deps.genRunId(),
374
+ budget: { total: budgetTotal - spent },
375
+ maxAgents: maxAgents - agentCount,
376
+ maxRecursionDepth: maxRecursion - 1,
377
+ }, deps);
378
+ if (childResult.status === "aborted") throw new Error(`child workflow '${name}' aborted: ${childResult.error ?? "unknown"}`);
379
+ for (const id of childResult.childRunIds) childRunIds.push(id);
380
+ spent += childResult.tokenTotal ?? 0;
381
+ costAccum += childResult.costTotal ?? 0;
382
+ return childResult.result;
383
+ };
384
+
385
+ // The HelperCtx shared by all 7 helpers.
386
+ const helperCtx: helpers.HelperCtx = {
387
+ spawn: async (prompt, hOpts) => {
388
+ const maxRetries = hOpts?.retries ?? 0;
389
+ let attempt = 0;
390
+ const buildOpts = () => ({
391
+ agent: hOpts?.agent ?? "reviewer",
392
+ ...(hOpts?.model ? { model: hOpts.model } : {}),
393
+ ...(hOpts?.tier ? { tier: hOpts.tier } : {}),
394
+ ...(hOpts?.skills ? { skills: hOpts.skills } : {}),
395
+ ...(hOpts?.backend ? { backend: hOpts.backend } : {}),
396
+ ...(hOpts?.timeoutMs ? { timeoutMs: hOpts.timeoutMs } : {}),
397
+ runId,
398
+ });
399
+ let res = await trackedSpawn(prompt, buildOpts());
400
+ while (res.status !== "completed" && attempt < maxRetries) {
401
+ if (deps.runtime?.signal.aborted) break;
402
+ attempt++;
403
+ res = await trackedSpawn(prompt, buildOpts());
404
+ }
405
+ return res as unknown as helpers.HelperSpawnResult;
406
+ },
407
+ journal: deps.journal,
408
+ runId,
409
+ ...(opts.budget ? { budget: { spent: () => spent, remaining: () => budgetTotal - spent } } : {}),
410
+ ...(deps.onCheckpoint ? { onCheckpoint: deps.onCheckpoint } : {}),
411
+ ...(deps.getModelContextWindow ? { getModelContextWindow: deps.getModelContextWindow } : {}),
412
+ nextCallIndex,
413
+ };
414
+
415
+ // Wrap each helper to journal helper:call (with callIndex + name + args) before calling,
416
+ // and helper:result after. The nextCallIndex fn is shared with agent() so the positional
417
+ // index is monotonic across all call types.
418
+ const wrapHelper = <A extends unknown[], R>(
419
+ name: string,
420
+ fn: (...args: A) => Promise<R>,
421
+ ): ((...args: A) => Promise<R>) => {
422
+ return async (...args: A): Promise<R> => {
423
+ const idx = nextCallIndex();
424
+ deps.journal.append(runId, { type: "helper:call", callIndex: idx, name, args, ts: Date.now() });
425
+ emitProgress("helper-started");
426
+ const result = await fn(...args);
427
+ deps.journal.append(runId, { type: "helper:result", callIndex: idx, name, result, ts: Date.now() });
428
+ emitProgress("helper-completed");
429
+ return result;
430
+ };
431
+ };
432
+
433
+ // Checkpoint is special-cased: it checks the resume cache BEFORE calling onCheckpoint/headless.
434
+ // The cache key is callIndex + prompt + JSON.stringify(opts). On resume, if the prior run's
435
+ // checkpoint at the same index has the same prompt + opts, reuse the response (no re-prompt).
436
+ const wrappedCheckpoint = async (prompt: string, cpOpts: Record<string, unknown> = {}): Promise<unknown> => {
437
+ await beforeDispatch();
438
+ const idx = nextCallIndex();
439
+ const optsHash = JSON.stringify(cpOpts);
440
+ deps.journal.append(runId, { type: "helper:call", callIndex: idx, name: "checkpoint", args: [prompt, cpOpts], ts: Date.now() });
441
+ emitProgress("checkpoint");
442
+
443
+ // Resume: check checkpoint cache before prompting. Cache key: callIndex + prompt + optsHash.
444
+ const cachedCp = checkpointCache.get(idx);
445
+ if (cachedCp && cachedCp.prompt === prompt && cachedCp.optsHash === optsHash) {
446
+ deps.journal.append(runId, { type: "helper:result", callIndex: idx, name: "checkpoint", result: cachedCp.response, ts: Date.now() });
447
+ deps.journal.append(runId, { type: "checkpoint", callIndex: idx, prompt, response: cachedCp.response, ts: Date.now() });
448
+ emitProgress("checkpoint-resolved");
449
+ return cachedCp.response;
450
+ }
451
+
452
+ const result = await helpers.checkpoint(prompt, cpOpts as never, helperCtx);
453
+ deps.journal.append(runId, { type: "helper:result", callIndex: idx, name: "checkpoint", result, ts: Date.now() });
454
+ deps.journal.append(runId, { type: "checkpoint", callIndex: idx, prompt, response: result, ts: Date.now() });
455
+ emitProgress("checkpoint-resolved");
456
+ return result;
457
+ };
458
+
459
+ const wrappedHelpers: RealmDeps = {
460
+ agent, parallel, pipeline, phase: phaseOf, workflow,
461
+ verify: wrapHelper("verify", (item: unknown, o?: Record<string, unknown>) => helpers.verify(item, (o ?? {}) as never, helperCtx)),
462
+ judgePanel: wrapHelper("judgePanel", (a: unknown[], o?: Record<string, unknown>) => helpers.judgePanel(a, (o ?? {}) as never, helperCtx)),
463
+ loopUntilDry: wrapHelper("loopUntilDry", (o: Record<string, unknown>) => helpers.loopUntilDry(o as never, helperCtx)),
464
+ completenessCheck: wrapHelper("completenessCheck", (t: unknown, r: unknown, o?: Record<string, unknown>) => helpers.completenessCheck(t, r, helperCtx, o as never)),
465
+ gate: wrapHelper("gate", (t: (fb: string | undefined, n: number) => unknown, v: (val: unknown) => { ok: boolean; feedback?: string }, o?: Record<string, unknown>) => helpers.gate(t as never, v as never, (o ?? {}) as never, helperCtx) as Promise<unknown>),
466
+ retry: wrapHelper("retry", (t: (n: number) => unknown, o?: Record<string, unknown>) => helpers.retry(t as never, (o ?? {}) as never, helperCtx)),
467
+ checkpoint: wrappedCheckpoint,
468
+ log,
469
+ args: opts.args,
470
+ cwd: process.cwd(),
471
+ budget: { total: budgetTotal, spent: () => spent, remaining: () => budgetTotal - spent },
472
+ };
473
+
474
+ const realm = buildRealm(wrappedHelpers);
475
+ deps.journal.append(runId, { type: "wf:started", runId, script: opts.sourceText ?? opts.script, args: opts.args, phases: [], mode: opts.mode, ts: Date.now() });
476
+ emitProgress("started");
477
+
478
+ try {
479
+ const script = compileWorkflowScript(opts.script);
480
+ const result = await script.runInContext(realm);
481
+ // Abort-wins guard: if signal aborted during execution, write wf:aborted (not wf:completed).
482
+ if (deps.runtime?.signal.aborted && !terminalWritten) {
483
+ terminalWritten = true;
484
+ const reason = deps.runtime.signal.reason instanceof Error
485
+ ? deps.runtime.signal.reason.message
486
+ : "workflow stopped";
487
+ emitProgress("aborted");
488
+ deps.journal.append(runId, { type: "wf:aborted", runId, reason, ts: Date.now() });
489
+ return { runId, status: "aborted", error: reason, logs, childRunIds, phases: [...phaseCounts.entries()].map(([title, c]) => ({ title, ...c })) };
490
+ }
491
+ if (!terminalWritten) {
492
+ terminalWritten = true;
493
+ emitProgress("completed");
494
+ deps.journal.append(runId, { type: "wf:completed", runId, result, ...(costAccum ? { costTotal: costAccum } : {}), ...(spent ? { tokenTotal: spent } : {}), ts: Date.now() });
495
+ }
496
+ return { runId, status: "completed", result, ...(costAccum ? { costTotal: costAccum } : {}), ...(spent ? { tokenTotal: spent } : {}), logs, childRunIds, phases: [...phaseCounts.entries()].map(([title, c]) => ({ title, ...c })) };
497
+ } catch (e) {
498
+ if (!terminalWritten) {
499
+ terminalWritten = true;
500
+ const reason = (e as Error).message;
501
+ emitProgress("aborted");
502
+ deps.journal.append(runId, { type: "wf:aborted", runId, reason, ts: Date.now() });
503
+ return { runId, status: "aborted", error: reason, logs, childRunIds, phases: [...phaseCounts.entries()].map(([title, c]) => ({ title, ...c })) };
504
+ }
505
+ return { runId, status: "aborted", error: (e as Error).message, logs, childRunIds, phases: [...phaseCounts.entries()].map(([title, c]) => ({ title, ...c })) };
506
+ }
507
+ }