pi-workflow-engine 0.9.0 → 0.10.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.
@@ -1,6 +1,5 @@
1
- import { spawn } from "node:child_process";
2
- import { performance } from "node:perf_hooks";
3
1
  import type { PerfSink } from "./perf.ts";
2
+ import { runBoundedProcess } from "./process-runner.ts";
4
3
 
5
4
  export interface AllowedDiffCommand {
6
5
  readonly file: "git" | "gh";
@@ -75,76 +74,23 @@ export async function captureDiff(command: string, options: DiffCaptureOptions):
75
74
  return { ok: false, stdout: "", durationMs: 0, bytes: 0, error: parsed.error };
76
75
  }
77
76
 
78
- const start = performance.now();
79
- let stdout = "";
80
- let stderr = "";
81
- let bytes = 0;
82
- let error: string | undefined;
83
- const child = spawn(parsed.file, [...parsed.args], {
77
+ const result = await runBoundedProcess({
78
+ file: parsed.file,
79
+ args: parsed.args,
84
80
  cwd: options.cwd,
85
81
  env: diffCaptureEnv(options.env),
86
- stdio: ["ignore", "pipe", "pipe"],
87
- });
88
-
89
- return await new Promise<DiffCaptureResult>((resolve) => {
90
- let settled = false;
91
- let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
92
- const killGraceMs = Math.max(1, options.killGraceMs ?? 100);
93
- const finish = (ok: boolean, finishError?: string) => {
94
- if (settled) return;
95
- settled = true;
96
- cleanup();
97
- const durationMs = performance.now() - start;
98
- options.perf?.observe("diff.capture_ms", durationMs);
99
- options.perf?.observe("diff.bytes", bytes);
100
- resolve({ ok, stdout, durationMs, bytes, error: finishError });
101
- };
102
- const kill = (message: string) => {
103
- error = error ?? message;
104
- child.kill("SIGTERM");
105
- forceKillTimer ??= setTimeout(() => {
106
- child.kill("SIGKILL");
107
- finish(false, error);
108
- }, killGraceMs);
109
- };
110
- const onAbort = () => kill("diff capture aborted");
111
- const timeout = setTimeout(() => kill(`diff capture timed out after ${options.timeoutMs}ms`), options.timeoutMs);
112
- const cleanup = () => {
113
- clearTimeout(timeout);
114
- if (forceKillTimer) clearTimeout(forceKillTimer);
115
- options.signal?.removeEventListener("abort", onAbort);
116
- };
117
-
118
- if (options.signal?.aborted) {
119
- kill("diff capture aborted");
120
- } else {
121
- options.signal?.addEventListener("abort", onAbort, { once: true });
122
- }
123
-
124
- child.stdout?.on("data", (chunk: Buffer) => {
125
- bytes += chunk.length;
126
- if (bytes > options.maxBufferBytes) {
127
- kill(`diff capture exceeded ${options.maxBufferBytes} bytes`);
128
- return;
129
- }
130
- stdout += chunk.toString("utf8");
131
- });
132
- child.stderr?.on("data", (chunk: Buffer) => {
133
- stderr += chunk.toString("utf8");
134
- });
135
- child.on("error", (spawnError) => finish(false, spawnError.message));
136
- child.on("close", (code, signal) => {
137
- if (error) {
138
- finish(false, error);
139
- return;
140
- }
141
- if (code === 0) {
142
- finish(true);
143
- return;
144
- }
145
- finish(false, stderr.trim() || `diff command exited with code ${code ?? `signal ${signal ?? "unknown"}`}`);
146
- });
82
+ signal: options.signal,
83
+ timeoutMs: options.timeoutMs,
84
+ maxBufferBytes: options.maxBufferBytes,
85
+ killGraceMs: options.killGraceMs,
86
+ abortError: "diff capture aborted",
87
+ timeoutError: `diff capture timed out after ${options.timeoutMs}ms`,
88
+ maxBufferError: `diff capture exceeded ${options.maxBufferBytes} bytes`,
89
+ exitError: (stderr, code, signal) => stderr.trim() || `diff command exited with code ${code ?? `signal ${signal ?? "unknown"}`}`,
147
90
  });
91
+ options.perf?.observe("diff.capture_ms", result.durationMs);
92
+ options.perf?.observe("diff.bytes", result.bytes);
93
+ return result;
148
94
  }
149
95
 
150
96
  function diffCaptureEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { parallel, pipeline, Semaphore } from "./concurrency.ts";
2
+ import { bindPipeline, parallel, Semaphore } from "./concurrency.ts";
3
3
  import { linkAbortSignal } from "./cancellation.ts";
4
+ import { createBudget } from "./budget.ts";
4
5
  import { runAgent, type RunContext } from "./agent-runner.ts";
5
6
  import { ProgressTracker } from "./progress.ts";
6
7
  import { createPerfRecorder, type PerfSnapshot } from "./perf.ts";
@@ -8,6 +9,8 @@ import { createWorkflowUsageRecorder } from "./usage.ts";
8
9
  import { defaultConcurrency, resolveWorkflowRunOptions } from "./options.ts";
9
10
  import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
10
11
  import { WorkflowInspector } from "./ui/workflow-inspector.ts";
12
+ import { createWorkflowJournal, createWorkflowRunId, pruneWorkflowJournals, workflowJournalPath } from "./journal.ts";
13
+ import { WorktreeRegistry } from "./worktree.ts";
11
14
 
12
15
  /** Default global cap on concurrent agents per run. */
13
16
  const DEFAULT_CONCURRENCY = defaultConcurrency();
@@ -60,6 +63,14 @@ export async function runWorkflow(
60
63
 
61
64
  const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
62
65
  const usage = createWorkflowUsageRecorder();
66
+ const budget = createBudget(resolvedOptions.budget ?? null, usage);
67
+ const runId = resolvedOptions.runId ?? createWorkflowRunId();
68
+ const journalPath = workflowJournalPath(ctx.cwd, runId);
69
+ const resumePath = resolvedOptions.resumeFromRunId ? workflowJournalPath(ctx.cwd, resolvedOptions.resumeFromRunId) : undefined;
70
+ const journal = await createWorkflowJournal({ resumePath, writePath: journalPath });
71
+ const worktrees = new WorktreeRegistry(ctx.cwd);
72
+ resolvedOptions.onRunMetadata?.({ runId, resumedFromRunId: resolvedOptions.resumeFromRunId, journalPath });
73
+ progress.log(resolvedOptions.resumeFromRunId ? `run id: ${runId} (resuming from ${resolvedOptions.resumeFromRunId})` : `run id: ${runId}`);
63
74
  const runAbortController = new AbortController();
64
75
  const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
65
76
  const unlinkOptionAbortSignal = linkAbortSignal(resolvedOptions.signal, runAbortController);
@@ -72,6 +83,9 @@ export async function runWorkflow(
72
83
  signal: runAbortController.signal,
73
84
  perf,
74
85
  usage,
86
+ budget,
87
+ journal,
88
+ worktrees,
75
89
  };
76
90
 
77
91
  try {
@@ -99,6 +113,13 @@ export async function runWorkflow(
99
113
  resolvedOptions.onProgressSource?.(undefined);
100
114
  unlinkContextAbortSignal();
101
115
  unlinkOptionAbortSignal();
116
+ const cleanupResults = await worktrees.removeAll();
117
+ for (const result of cleanupResults) {
118
+ if (!result.ok) {
119
+ progress.log(`failed to remove isolated worktree ${result.path} (${result.error ?? (result.stderr.trim() || "unknown error")})`);
120
+ }
121
+ }
122
+ await pruneWorkflowJournals(ctx.cwd);
102
123
  }
103
124
  }
104
125
  }
@@ -156,12 +177,13 @@ export async function runWorkflowWithContext(
156
177
  abortController: opts.abortController,
157
178
  limit: opts.submissionLimit,
158
179
  }),
159
- pipeline,
180
+ pipeline: bindPipeline({ signal: rc.signal, abortController: opts.abortController }),
160
181
  phase: scope.phase,
161
182
  log: scope.log,
162
183
  progress: scope.event,
163
184
  args,
164
185
  cwd: rc.cwd,
186
+ budget: rc.budget,
165
187
  signal: rc.signal,
166
188
  };
167
189
 
@@ -0,0 +1,200 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readdir, readFile, rm, stat, appendFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import type { AgentOptions } from "./types.ts";
5
+
6
+ export const WORKFLOW_RUNS_DIR = join(".pi", ".workflow-runs");
7
+ export const WORKFLOW_JOURNAL_KEEP = 50;
8
+
9
+ export interface JournalEntry {
10
+ readonly key: string;
11
+ readonly value: unknown;
12
+ }
13
+
14
+ export type JournalLookup = { readonly hit: true; readonly value: unknown } | { readonly hit: false };
15
+ export type JournalRecordResult = { readonly ok: true } | { readonly ok: false; readonly error: string };
16
+
17
+ export interface WorkflowJournal {
18
+ lookup(key: string): JournalLookup;
19
+ record(key: string, value: unknown): Promise<JournalRecordResult>;
20
+ }
21
+
22
+ export class WorkflowJournalLoadError extends Error {
23
+ override readonly name = "WorkflowJournalLoadError";
24
+ }
25
+
26
+ export function createWorkflowRunId(): string {
27
+ return randomUUID();
28
+ }
29
+
30
+ export function workflowJournalPath(cwd: string, runId: string): string {
31
+ return join(cwd, WORKFLOW_RUNS_DIR, `${validateRunId(runId)}.jsonl`);
32
+ }
33
+
34
+ export function agentJournalKey(prompt: string, opts: AgentOptions = {}): string {
35
+ const behaviorHash = hashAgentCall(prompt, opts);
36
+ const cacheKey = opts.cacheKey?.trim();
37
+ if (!cacheKey) return `agent:${behaviorHash}`;
38
+ const cacheKeyHash = createHash("sha256").update(cacheKey).digest("hex");
39
+ return `agent:${cacheKeyHash}:${behaviorHash}`;
40
+ }
41
+
42
+ export function hashAgentCall(prompt: string, opts: AgentOptions = {}): string {
43
+ const behavior = {
44
+ prompt,
45
+ opts: {
46
+ model: opts.model,
47
+ thinkingLevel: opts.thinkingLevel,
48
+ tools: sortedArray(opts.tools),
49
+ toolHints: sortedArray(opts.toolHints),
50
+ skills: sortedArray(opts.skills),
51
+ schema: opts.schema,
52
+ isolation: opts.isolation,
53
+ },
54
+ };
55
+ return createHash("sha256").update(stableStringify(behavior)).digest("hex");
56
+ }
57
+
58
+ export async function loadJournalEntries(path: string, options: { readonly required?: boolean } = {}): Promise<JournalEntry[]> {
59
+ let content: string;
60
+ try {
61
+ content = await readFile(path, "utf8");
62
+ } catch (error) {
63
+ if (options.required) {
64
+ throw new WorkflowJournalLoadError(`Could not load workflow resume journal at ${path}: ${formatError(error)}`);
65
+ }
66
+ return [];
67
+ }
68
+
69
+ const entries: JournalEntry[] = [];
70
+ let lineNumber = 0;
71
+ for (const line of content.split(/\r?\n/)) {
72
+ lineNumber += 1;
73
+ if (line.trim() === "") continue;
74
+ try {
75
+ const parsed = JSON.parse(line) as unknown;
76
+ if (isJournalEntry(parsed)) {
77
+ entries.push(parsed);
78
+ continue;
79
+ }
80
+ if (options.required) {
81
+ throw new WorkflowJournalLoadError(`Workflow resume journal at ${path}:${lineNumber} has an incompatible entry format.`);
82
+ }
83
+ } catch (error) {
84
+ if (options.required) {
85
+ if (error instanceof WorkflowJournalLoadError) throw error;
86
+ throw new WorkflowJournalLoadError(`Workflow resume journal at ${path}:${lineNumber} is not valid JSON.`);
87
+ }
88
+ }
89
+ }
90
+ return entries;
91
+ }
92
+
93
+ export async function createWorkflowJournal(options: {
94
+ readonly resumePath?: string;
95
+ readonly writePath: string;
96
+ }): Promise<WorkflowJournal> {
97
+ const priorEntries = options.resumePath ? await loadJournalEntries(options.resumePath, { required: true }) : [];
98
+ return createMemoryBackedJournal(priorEntries, options.writePath);
99
+ }
100
+
101
+ export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[] = [], writePath?: string): WorkflowJournal {
102
+ const priorByKey = new Map<string, JournalEntry | "ambiguous">();
103
+ for (const entry of priorEntries) {
104
+ priorByKey.set(entry.key, priorByKey.has(entry.key) ? "ambiguous" : entry);
105
+ }
106
+
107
+ return {
108
+ lookup(key) {
109
+ const entry = priorByKey.get(key);
110
+ if (!entry || entry === "ambiguous") return { hit: false };
111
+ return { hit: true, value: entry.value };
112
+ },
113
+ async record(key, value) {
114
+ const entry = { key, value };
115
+ if (writePath) {
116
+ try {
117
+ await mkdir(dirname(writePath), { recursive: true });
118
+ await appendFile(writePath, `${JSON.stringify(entry)}\n`, "utf8");
119
+ } catch (error) {
120
+ return { ok: false, error: formatError(error) };
121
+ }
122
+ }
123
+ return { ok: true };
124
+ },
125
+ };
126
+ }
127
+
128
+ export async function pruneWorkflowJournals(cwd: string, keep = WORKFLOW_JOURNAL_KEEP): Promise<void> {
129
+ const dir = join(cwd, WORKFLOW_RUNS_DIR);
130
+ let files: string[];
131
+ try {
132
+ files = await readdir(dir);
133
+ } catch {
134
+ return;
135
+ }
136
+
137
+ const candidates = await Promise.all(
138
+ files
139
+ .filter((file) => file.endsWith(".jsonl"))
140
+ .map(async (file) => {
141
+ const path = join(dir, file);
142
+ try {
143
+ const info = await stat(path);
144
+ return { path, mtimeMs: info.mtimeMs };
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ }),
149
+ );
150
+
151
+ const stale = candidates
152
+ .filter((entry): entry is { readonly path: string; readonly mtimeMs: number } => entry !== undefined)
153
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
154
+ .slice(Math.max(0, keep));
155
+
156
+ await Promise.all(stale.map((entry) => rm(entry.path, { force: true }).catch(() => undefined)));
157
+ }
158
+
159
+ function validateRunId(runId: string): string {
160
+ const trimmed = runId.trim();
161
+ if (!/^[A-Za-z0-9_.-]+$/.test(trimmed)) {
162
+ throw new Error("Workflow run id must contain only letters, numbers, dots, underscores, or hyphens.");
163
+ }
164
+ return trimmed;
165
+ }
166
+
167
+ function sortedArray(values: readonly string[] | undefined): readonly string[] | undefined {
168
+ return values ? [...values].sort() : undefined;
169
+ }
170
+
171
+ function stableStringify(value: unknown): string {
172
+ return JSON.stringify(normalizeStable(value));
173
+ }
174
+
175
+ function normalizeStable(value: unknown): unknown {
176
+ if (value === undefined) return undefined;
177
+ if (value === null) return null;
178
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
179
+ if (Array.isArray(value)) return value.map(normalizeStable);
180
+ if (typeof value === "object") {
181
+ const record = value as Record<string, unknown>;
182
+ const out: Record<string, unknown> = {};
183
+ for (const key of Object.keys(record).sort()) {
184
+ const normalized = normalizeStable(record[key]);
185
+ if (normalized !== undefined) out[key] = normalized;
186
+ }
187
+ return out;
188
+ }
189
+ return String(value);
190
+ }
191
+
192
+ function isJournalEntry(value: unknown): value is JournalEntry {
193
+ if (typeof value !== "object" || value === null) return false;
194
+ const entry = value as { readonly key?: unknown; readonly value?: unknown };
195
+ return typeof entry.key === "string" && "value" in entry;
196
+ }
197
+
198
+ function formatError(error: unknown): string {
199
+ return error instanceof Error ? error.message : String(error);
200
+ }
@@ -1,10 +1,14 @@
1
1
  import { cpus } from "node:os";
2
2
  import type { WorkflowRunOptions } from "./types.ts";
3
3
 
4
+ export const WORKFLOW_BUDGET_MIN = 1;
5
+ export const WORKFLOW_BUDGET_MAX = 1_000_000_000;
6
+
4
7
  export interface ResolvedWorkflowRunOptions extends WorkflowRunOptions {
5
8
  readonly perf: boolean;
6
9
  readonly concurrency: number;
7
10
  readonly parallelSubmissionLimit?: number;
11
+ readonly budget?: number;
8
12
  }
9
13
 
10
14
  export function defaultConcurrency(cpuCount = cpus().length): number {
@@ -21,16 +25,18 @@ export function resolveWorkflowRunOptions(
21
25
  1,
22
26
  10_000,
23
27
  );
28
+ const budget = resolveBudget(input.budget, env.PI_WORKFLOW_BUDGET);
24
29
  return {
25
30
  ...input,
26
31
  perf: input.perf ?? env.PI_WORKFLOW_PERF === "1",
27
32
  concurrency,
28
33
  parallelSubmissionLimit,
34
+ budget,
29
35
  };
30
36
  }
31
37
 
32
38
  function optionalClampedInteger(value: number | undefined, min: number, max: number): number | undefined {
33
- if (value === undefined) return undefined;
39
+ if (value === undefined || !Number.isFinite(value)) return undefined;
34
40
  return clampInteger(value, min, max, value);
35
41
  }
36
42
 
@@ -44,3 +50,23 @@ function parseInteger(value: string | undefined): number | undefined {
44
50
  const parsed = Number(value);
45
51
  return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
46
52
  }
53
+
54
+ function resolveBudget(inputBudget: number | undefined, envBudget: string | undefined): number | undefined {
55
+ if (inputBudget !== undefined) return normalizeExplicitBudget(inputBudget);
56
+ return envBudget === undefined ? undefined : parseWorkflowBudgetString(envBudget);
57
+ }
58
+
59
+ function normalizeExplicitBudget(value: number): number {
60
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < WORKFLOW_BUDGET_MIN || value > WORKFLOW_BUDGET_MAX) {
61
+ throw new RangeError(`Workflow budget must be an integer between ${WORKFLOW_BUDGET_MIN} and ${WORKFLOW_BUDGET_MAX}.`);
62
+ }
63
+ return value;
64
+ }
65
+
66
+ export function parseWorkflowBudgetString(value: string): number | undefined {
67
+ const trimmed = value.trim();
68
+ if (!/^\d+$/.test(trimmed)) return undefined;
69
+ const parsed = Number(trimmed);
70
+ if (!Number.isSafeInteger(parsed) || parsed < WORKFLOW_BUDGET_MIN || parsed > WORKFLOW_BUDGET_MAX) return undefined;
71
+ return parsed;
72
+ }
@@ -0,0 +1,96 @@
1
+ import { spawn } from "node:child_process";
2
+ import { performance } from "node:perf_hooks";
3
+
4
+ export interface BoundedProcessOptions {
5
+ readonly file: string;
6
+ readonly args: readonly string[];
7
+ readonly cwd: string;
8
+ readonly env?: NodeJS.ProcessEnv;
9
+ readonly signal?: AbortSignal;
10
+ readonly timeoutMs: number;
11
+ readonly maxBufferBytes?: number;
12
+ readonly killGraceMs?: number;
13
+ readonly abortError: string;
14
+ readonly timeoutError: string;
15
+ readonly maxBufferError?: string;
16
+ readonly exitError: (stderr: string, code: number | null, signal: NodeJS.Signals | null) => string;
17
+ }
18
+
19
+ export interface BoundedProcessResult {
20
+ readonly ok: boolean;
21
+ readonly stdout: string;
22
+ readonly stderr: string;
23
+ readonly durationMs: number;
24
+ readonly bytes: number;
25
+ readonly error?: string;
26
+ }
27
+
28
+ export async function runBoundedProcess(options: BoundedProcessOptions): Promise<BoundedProcessResult> {
29
+ const start = performance.now();
30
+ let stdout = "";
31
+ let stderr = "";
32
+ let bytes = 0;
33
+ let error: string | undefined;
34
+ const child = spawn(options.file, [...options.args], {
35
+ cwd: options.cwd,
36
+ env: options.env ?? process.env,
37
+ stdio: ["ignore", "pipe", "pipe"],
38
+ });
39
+
40
+ return await new Promise<BoundedProcessResult>((resolve) => {
41
+ let settled = false;
42
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
43
+ const killGraceMs = Math.max(1, options.killGraceMs ?? 100);
44
+ const finish = (ok: boolean, finishError?: string) => {
45
+ if (settled) return;
46
+ settled = true;
47
+ cleanup();
48
+ resolve({ ok, stdout, stderr, durationMs: performance.now() - start, bytes, error: finishError });
49
+ };
50
+ const kill = (message: string) => {
51
+ error = error ?? message;
52
+ child.kill("SIGTERM");
53
+ forceKillTimer ??= setTimeout(() => {
54
+ child.kill("SIGKILL");
55
+ finish(false, error);
56
+ }, killGraceMs);
57
+ };
58
+ const onAbort = () => kill(options.abortError);
59
+ const timeout = setTimeout(() => kill(options.timeoutError), options.timeoutMs);
60
+ const cleanup = () => {
61
+ clearTimeout(timeout);
62
+ if (forceKillTimer) clearTimeout(forceKillTimer);
63
+ options.signal?.removeEventListener("abort", onAbort);
64
+ };
65
+
66
+ if (options.signal?.aborted) {
67
+ kill(options.abortError);
68
+ } else {
69
+ options.signal?.addEventListener("abort", onAbort, { once: true });
70
+ }
71
+
72
+ child.stdout?.on("data", (chunk: Buffer) => {
73
+ bytes += chunk.length;
74
+ if (options.maxBufferBytes !== undefined && bytes > options.maxBufferBytes) {
75
+ kill(options.maxBufferError ?? `process output exceeded ${options.maxBufferBytes} bytes`);
76
+ return;
77
+ }
78
+ stdout += chunk.toString("utf8");
79
+ });
80
+ child.stderr?.on("data", (chunk: Buffer) => {
81
+ stderr += chunk.toString("utf8");
82
+ });
83
+ child.on("error", (spawnError) => finish(false, spawnError.message));
84
+ child.on("close", (code, signal) => {
85
+ if (error) {
86
+ finish(false, error);
87
+ return;
88
+ }
89
+ if (code === 0) {
90
+ finish(true);
91
+ return;
92
+ }
93
+ finish(false, options.exitError(stderr, code, signal));
94
+ });
95
+ });
96
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Static, TSchema } from "typebox";
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
+ import type { WorkflowBudget } from "./budget.ts";
3
4
  import type { Pipeline } from "./concurrency.ts";
4
5
  import type { PerfSink, PerfSnapshot } from "./perf.ts";
5
6
  import type { WorkflowProgressSnapshot } from "./progress.ts";
@@ -23,11 +24,23 @@ export interface WorkflowRunStats {
23
24
  [key: string]: string | number;
24
25
  }
25
26
 
27
+ export interface WorkflowRunMetadata {
28
+ readonly runId: string;
29
+ readonly resumedFromRunId?: string;
30
+ readonly journalPath: string;
31
+ }
32
+
26
33
  export interface WorkflowRunOptions {
27
34
  inspect?: boolean;
28
35
  perf?: boolean;
29
36
  concurrency?: number;
30
37
  parallelSubmissionLimit?: number;
38
+ /** Output-token ceiling for the whole run. Omit for no limit (budget.total === null). */
39
+ budget?: number;
40
+ /** Internal/test override for the generated run id. Omit to generate a new id. */
41
+ runId?: string;
42
+ /** Replay completed agent results from this prior run id when prompt/options hashes still match. */
43
+ resumeFromRunId?: string;
31
44
  resultViewer?: "open" | "skip";
32
45
  /** Additional abort signal to compose with the host context signal. */
33
46
  signal?: AbortSignal;
@@ -39,6 +52,8 @@ export interface WorkflowRunOptions {
39
52
  onPerfSnapshot?: (snapshot: PerfSnapshot) => void;
40
53
  /** Called with the final workflow subagent usage snapshot. */
41
54
  onUsageSnapshot?: (snapshot: WorkflowUsageSnapshot) => void;
55
+ /** Called once run identity and journal paths are known. */
56
+ onRunMetadata?: (metadata: WorkflowRunMetadata) => void;
42
57
  /** Called with the live progress source while a workflow is running, then undefined when it ends. */
43
58
  onProgressSource?: (source: WorkflowProgressSource | undefined) => void;
44
59
  /** Called with the final completed progress snapshot after live workflow UI teardown. */
@@ -52,6 +67,14 @@ export interface WorkflowProgressSource {
52
67
  /** Options for a single `agent()` call. */
53
68
  export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
54
69
 
70
+ export type AgentToolHint = "search";
71
+
72
+ export interface IsolatedAgentResult<T> {
73
+ readonly result: T;
74
+ readonly patch: string;
75
+ readonly changed: boolean;
76
+ }
77
+
55
78
  export type WorkflowProgressEvent =
56
79
  | { type: "counter"; key: string; label: string; value: number }
57
80
  | { type: "counter_delta"; key: string; label: string; delta: number }
@@ -77,8 +100,21 @@ export interface AgentOptions<S extends TSchema = TSchema> {
77
100
  model?: string;
78
101
  /** Reasoning effort for this agent. */
79
102
  thinkingLevel?: ThinkingLevel;
80
- /** Allowlist of built-in tool names the agent may use (e.g. ["read", "bash"]). */
103
+ /**
104
+ * Stable identity hint for resume replay. Use this for repeated logical calls
105
+ * with identical prompts/options, e.g. `${stage}:${item.id}`.
106
+ */
107
+ cacheKey?: string;
108
+ /** Run this agent in a disposable git worktree and return its patch with the result. */
109
+ isolation?: "worktree";
110
+ /** Allowlist of concrete tool names the agent may use (e.g. ["read", "bash"]). */
81
111
  tools?: string[];
112
+ /**
113
+ * Dynamically include installed tools matching semantic categories. Currently
114
+ * "search" matches grep/find/code-search style tools such as ast-grep,
115
+ * mgrep, ffgrep, fffind, ripgrep wrappers, and pi's built-in grep/find/ls.
116
+ */
117
+ toolHints?: readonly AgentToolHint[];
82
118
  /**
83
119
  * Skill names to expose to this subagent. Subagents receive no skills by default.
84
120
  * When omitted, clear prompt text such as `/skill:name`, `include skill name`, or
@@ -95,6 +131,13 @@ export interface AgentOptions<S extends TSchema = TSchema> {
95
131
  * exports `meta` plus a default `async (api: WorkflowApi) => result`.
96
132
  */
97
133
  export interface WorkflowApi {
134
+ /** Run a schema subagent in a disposable worktree and return its structured result plus patch. */
135
+ agent<S extends TSchema>(
136
+ prompt: string,
137
+ opts: AgentOptions<S> & { schema: S; isolation: "worktree" },
138
+ ): Promise<IsolatedAgentResult<Static<S> | null>>;
139
+ /** Run a text subagent in a disposable worktree and return its final text plus patch. */
140
+ agent(prompt: string, opts: AgentOptions & { isolation: "worktree" }): Promise<IsolatedAgentResult<string>>;
98
141
  /** Run a subagent and return its validated structured output (null if it never produced one). */
99
142
  agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S> | null>;
100
143
  /** Run a subagent and return its final assistant text. */
@@ -106,9 +149,15 @@ export interface WorkflowApi {
106
149
  * a missing sub-workflow gracefully.
107
150
  */
108
151
  workflow(ref: WorkflowRef, args?: string): Promise<unknown>;
109
- /** Run every thunk concurrently and wait for all (a barrier). */
110
- parallel<T>(thunks: Array<() => Promise<T>>): Promise<T[]>;
111
- /** Run each item through all stages independently — no barrier between stages. */
152
+ /**
153
+ * Run every thunk concurrently and wait for all (a barrier). Recoverable thunk
154
+ * failures become null slots; filter nulls before consuming survivors.
155
+ */
156
+ parallel<T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
157
+ /**
158
+ * Run each item through all stages independently — no barrier between stages.
159
+ * A recoverable stage failure drops that item to null and skips its later stages.
160
+ */
112
161
  pipeline: Pipeline;
113
162
  /** Start a new phase; subsequent agents group under it in the progress tree. */
114
163
  phase(title: string): void;
@@ -120,6 +169,12 @@ export interface WorkflowApi {
120
169
  args: string;
121
170
  /** Working directory of the host session (typically the repo root). */
122
171
  cwd: string;
172
+ /**
173
+ * Token budget for the run. `budget.total` is the output-token ceiling (null when unset),
174
+ * `spent()`/`remaining()` are live. Guard loops with `while (budget.total && budget.remaining() > N)`;
175
+ * `agent()` throws `WorkflowBudgetExceededError` once the ceiling is reached.
176
+ */
177
+ budget: WorkflowBudget;
123
178
  /** Abort signal for the run, propagated from the host. */
124
179
  signal: AbortSignal | undefined;
125
180
  }