pi-workflow-engine 0.9.0 → 0.10.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/.pi/extensions/pi-workflow-engine/index.ts +132 -26
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +247 -23
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +46 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +7 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +83 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +15 -69
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +24 -2
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +200 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +27 -1
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +96 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +59 -4
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +81 -13
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +14 -5
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +238 -0
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +10 -5
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +12 -8
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +6 -2
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +6 -2
- package/README.md +10 -1
- package/USAGE.md +50 -8
- package/package.json +8 -8
|
@@ -5,23 +5,39 @@ import type { AgentOptions } from "./types.ts";
|
|
|
5
5
|
import type { Semaphore } from "./concurrency.ts";
|
|
6
6
|
import type { PerfSink } from "./perf.ts";
|
|
7
7
|
import type { WorkflowUsageSink } from "./usage.ts";
|
|
8
|
+
import { WorkflowBudgetExceededError, type WorkflowBudget } from "./budget.ts";
|
|
8
9
|
import { throwIfAborted } from "./cancellation.ts";
|
|
9
10
|
import { appendSkillReminder, createAgentSkillResourceLoader, extractSkillSelectorsFromText } from "./agent-skills.ts";
|
|
11
|
+
import { agentJournalKey, type WorkflowJournal } from "./journal.ts";
|
|
12
|
+
import type { WorktreeRegistry } from "./worktree.ts";
|
|
10
13
|
|
|
11
14
|
/** Name of the synthetic terminating tool that carries structured output. */
|
|
12
15
|
const FINAL_TOOL = "final_answer";
|
|
16
|
+
const SCHEMA_REPROMPT_ATTEMPTS = 1;
|
|
17
|
+
const SCHEMA_REPROMPT =
|
|
18
|
+
`You ended your turn without calling the ${FINAL_TOOL} tool, so no result was recorded. ` +
|
|
19
|
+
`Call ${FINAL_TOOL} now with your final answer as its arguments. Do not reply with plain text.`;
|
|
20
|
+
const BUILTIN_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const;
|
|
21
|
+
const DEFAULT_SEARCH_BASE_TOOLS = ["read", "bash", "grep", "find", "ls"];
|
|
13
22
|
|
|
14
23
|
export interface AgentRunnerEvent {
|
|
15
24
|
readonly type: string;
|
|
16
25
|
readonly toolName?: string;
|
|
17
26
|
}
|
|
18
27
|
|
|
28
|
+
export interface AgentRunnerToolInfo {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly description?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
export interface AgentRunnerSession {
|
|
20
34
|
readonly state: { readonly messages: readonly unknown[] };
|
|
21
35
|
prompt(text: string): Promise<void>;
|
|
22
36
|
subscribe(listener: (event: AgentRunnerEvent) => void): () => void;
|
|
23
37
|
dispose(): void;
|
|
24
38
|
abort(): Promise<void>;
|
|
39
|
+
getAllTools?(): readonly AgentRunnerToolInfo[];
|
|
40
|
+
setActiveToolsByName?(toolNames: readonly string[]): void;
|
|
25
41
|
}
|
|
26
42
|
|
|
27
43
|
export type CreateAgentSession = (options: CreateAgentSessionOptions) => Promise<{ session: AgentRunnerSession }>;
|
|
@@ -45,6 +61,9 @@ export interface RunContext {
|
|
|
45
61
|
signal: AbortSignal | undefined;
|
|
46
62
|
perf: PerfSink;
|
|
47
63
|
usage: WorkflowUsageSink;
|
|
64
|
+
budget: WorkflowBudget;
|
|
65
|
+
journal: WorkflowJournal;
|
|
66
|
+
worktrees: WorktreeRegistry;
|
|
48
67
|
createSession?: CreateAgentSession;
|
|
49
68
|
}
|
|
50
69
|
|
|
@@ -157,20 +176,173 @@ function linkSessionAbort(signal: AbortSignal | undefined, session: AgentRunnerS
|
|
|
157
176
|
return () => signal.removeEventListener("abort", onAbort);
|
|
158
177
|
}
|
|
159
178
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
179
|
+
type ToolSessionOptions = Pick<CreateAgentSessionOptions, "tools" | "excludeTools">;
|
|
180
|
+
|
|
181
|
+
interface ToolSelection {
|
|
182
|
+
readonly sessionOptions: ToolSessionOptions;
|
|
183
|
+
readonly fallbackSessionOptions: ToolSessionOptions;
|
|
184
|
+
readonly activeTools?: readonly string[];
|
|
185
|
+
readonly toolHints: NonNullable<AgentOptions["toolHints"]>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface DynamicToolSession extends AgentRunnerSession {
|
|
189
|
+
getAllTools(): readonly AgentRunnerToolInfo[];
|
|
190
|
+
setActiveToolsByName(toolNames: readonly string[]): void;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildToolList(opts: AgentOptions, skillsEnabled: boolean, fallback?: readonly string[]): string[] | undefined {
|
|
194
|
+
const allow = opts.tools ? [...opts.tools] : fallback ? [...fallback] : undefined;
|
|
195
|
+
if (!allow) return undefined;
|
|
163
196
|
if (skillsEnabled && !allow.includes("read")) allow.push("read");
|
|
164
197
|
if (opts.schema && !allow.includes(FINAL_TOOL)) allow.push(FINAL_TOOL);
|
|
165
198
|
return allow;
|
|
166
199
|
}
|
|
167
200
|
|
|
201
|
+
function buildToolSelection(opts: AgentOptions, skillsEnabled: boolean): ToolSelection {
|
|
202
|
+
const toolHints = opts.toolHints ?? [];
|
|
203
|
+
const fallback = toolHints.includes("search") ? DEFAULT_SEARCH_BASE_TOOLS : undefined;
|
|
204
|
+
const activeTools = buildToolList(opts, skillsEnabled, fallback);
|
|
205
|
+
const strictSessionOptions = { tools: activeTools ? [...activeTools] : undefined };
|
|
206
|
+
|
|
207
|
+
if (toolHints.length === 0) {
|
|
208
|
+
return { sessionOptions: strictSessionOptions, fallbackSessionOptions: strictSessionOptions, activeTools, toolHints };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const explicitlyActive = new Set(activeTools ?? []);
|
|
212
|
+
return {
|
|
213
|
+
sessionOptions: { excludeTools: BUILTIN_TOOL_NAMES.filter((name) => !explicitlyActive.has(name)) },
|
|
214
|
+
// If dynamic tool APIs are unavailable, fail closed to the concrete tools we
|
|
215
|
+
// already know instead of keeping every extension/custom tool active.
|
|
216
|
+
fallbackSessionOptions: { tools: activeTools ? [...activeTools] : [] },
|
|
217
|
+
activeTools,
|
|
218
|
+
toolHints,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function hasDynamicToolApis(session: AgentRunnerSession): session is DynamicToolSession {
|
|
223
|
+
return typeof session.getAllTools === "function" && typeof session.setActiveToolsByName === "function";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function applyDynamicToolHints(session: AgentRunnerSession, selection: ToolSelection): boolean {
|
|
227
|
+
if (selection.toolHints.length === 0) return true;
|
|
228
|
+
if (!hasDynamicToolApis(session)) return false;
|
|
229
|
+
|
|
230
|
+
const active = new Set(selection.activeTools ?? []);
|
|
231
|
+
for (const tool of session.getAllTools()) {
|
|
232
|
+
if (matchesToolHints(tool, selection.toolHints)) active.add(tool.name);
|
|
233
|
+
}
|
|
234
|
+
session.setActiveToolsByName([...active]);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function matchesToolHints(tool: AgentRunnerToolInfo, toolHints: readonly string[]): boolean {
|
|
239
|
+
return toolHints.some((hint) => hint === "search" && isSearchLikeTool(tool));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isSearchLikeTool(tool: AgentRunnerToolInfo): boolean {
|
|
243
|
+
if (isClearlyMutatingToolName(tool.name)) return false;
|
|
244
|
+
const name = tool.name.toLowerCase();
|
|
245
|
+
if (name.includes("grep") || name.includes("find") || name.includes("search") || name === "rg" || name.includes("ripgrep")) return true;
|
|
246
|
+
const description = tool.description?.toLowerCase() ?? "";
|
|
247
|
+
return /\b(?:grep|find|search|ripgrep|rg|structural search|code search)\b/.test(description);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isClearlyMutatingToolName(name: string): boolean {
|
|
251
|
+
return /(?:^|[-_])(?:edit|write|replace|patch|apply|delete|remove|move|rename|create|commit|push)(?:$|[-_])/.test(name.toLowerCase());
|
|
252
|
+
}
|
|
253
|
+
|
|
168
254
|
function shouldCreateSkillResourceLoader(rc: RunContext, prompt: string, opts: AgentOptions): boolean {
|
|
169
255
|
if (!rc.createSession) return true;
|
|
170
256
|
if (opts.skills !== undefined) return true;
|
|
171
257
|
return extractSkillSelectorsFromText(prompt).length > 0;
|
|
172
258
|
}
|
|
173
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Refuse to start a new subagent once the run is over budget. Called before queueing and
|
|
262
|
+
* again after a queued agent acquires a concurrency slot, so an exhausted run stops spending
|
|
263
|
+
* immediately rather than starting stale queued work.
|
|
264
|
+
*
|
|
265
|
+
* Policy: accept overshoot (matches the built-in Workflow tool). `budget.spent()` counts
|
|
266
|
+
* only COMPLETED agents (usage is recorded on session dispose), so agents admitted together
|
|
267
|
+
* can each pass this check and then collectively overshoot `budget.total` — we tolerate that
|
|
268
|
+
* rather than reserving per-agent estimates. An uncapped run (`budget.total === null`,
|
|
269
|
+
* `remaining()` is Infinity) never throws.
|
|
270
|
+
*
|
|
271
|
+
* Heads-up: a throw here is a recoverable branch failure inside `parallel`/`pipeline`, so the
|
|
272
|
+
* over-budget slot becomes null while already-started siblings can finish. The intended primary
|
|
273
|
+
* usage is the loop guard `while (budget.total && budget.remaining() > N) { await agent(...) }`,
|
|
274
|
+
* where this throw is only a backstop.
|
|
275
|
+
*/
|
|
276
|
+
function ensureWithinBudget(budget: WorkflowBudget): void {
|
|
277
|
+
if (budget.total !== null && budget.remaining() <= 0) {
|
|
278
|
+
throw new WorkflowBudgetExceededError(budget.total, budget.spent());
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface AgentWorkspace {
|
|
283
|
+
readonly cwd: string;
|
|
284
|
+
wrapResult(result: unknown): Promise<unknown>;
|
|
285
|
+
dispose(): Promise<void>;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function createAgentWorkspace(rc: RunContext, opts: AgentOptions, label: string): Promise<AgentWorkspace> {
|
|
289
|
+
if (opts.isolation !== "worktree") {
|
|
290
|
+
return {
|
|
291
|
+
cwd: rc.cwd,
|
|
292
|
+
async wrapResult(result) {
|
|
293
|
+
return result;
|
|
294
|
+
},
|
|
295
|
+
async dispose() {},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const probe = await rc.worktrees.probe(rc.signal);
|
|
300
|
+
if (!probe.ok) {
|
|
301
|
+
throw new Error(`Failed to check git worktree availability for isolated agent: ${probe.error ?? "unknown git error"}`);
|
|
302
|
+
}
|
|
303
|
+
if (!probe.inside) {
|
|
304
|
+
throw new Error("Agent requested worktree isolation, but the workflow cwd is not inside a git work tree.");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const added = await rc.worktrees.add(rc.signal);
|
|
308
|
+
if ("error" in added) throw new Error(`Failed to create isolated worktree: ${added.error}`);
|
|
309
|
+
const worktreePath = added.path;
|
|
310
|
+
rc.progress.log(`${label}: using isolated worktree ${worktreePath}`);
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
cwd: worktreePath,
|
|
314
|
+
async wrapResult(result) {
|
|
315
|
+
return await isolatedResult(rc, worktreePath, result);
|
|
316
|
+
},
|
|
317
|
+
async dispose() {
|
|
318
|
+
const removed = await rc.worktrees.remove(worktreePath);
|
|
319
|
+
if (!removed.ok) {
|
|
320
|
+
rc.progress.log(`${label}: failed to remove isolated worktree (${removed.error ?? (removed.stderr.trim() || "unknown error")})`);
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function disposeAgentWorkspace(rc: RunContext, label: string, workspace: AgentWorkspace | undefined): Promise<void> {
|
|
327
|
+
if (!workspace) return;
|
|
328
|
+
try {
|
|
329
|
+
await workspace.dispose();
|
|
330
|
+
} catch (error) {
|
|
331
|
+
rc.progress.log(`${label}: failed to dispose isolated workspace (${errorMessage(error)})`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function recordJournalResult(rc: RunContext, label: string, key: string, value: unknown): Promise<void> {
|
|
336
|
+
try {
|
|
337
|
+
const recorded = await rc.journal.record(key, value);
|
|
338
|
+
if (!recorded.ok) {
|
|
339
|
+
rc.progress.log(`${label}: workflow journal write failed (${recorded.error}); future resume may be incomplete`);
|
|
340
|
+
}
|
|
341
|
+
} catch (error) {
|
|
342
|
+
rc.progress.log(`${label}: workflow journal write failed (${errorMessage(error)}); future resume may be incomplete`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
174
346
|
/**
|
|
175
347
|
* Run one subagent to completion in an isolated in-memory session.
|
|
176
348
|
*
|
|
@@ -188,6 +360,16 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
188
360
|
|
|
189
361
|
return await rc.perf.time("agent.total_ms", async () => {
|
|
190
362
|
throwIfAborted(rc.signal);
|
|
363
|
+
const journalKey = agentJournalKey(prompt, opts);
|
|
364
|
+
const cached = rc.journal.lookup(journalKey);
|
|
365
|
+
if (cached.hit) {
|
|
366
|
+
rc.progress.log(`${label}: using cached result from workflow journal`);
|
|
367
|
+
rc.perf.counter("agent.cache_hit", 1, tags);
|
|
368
|
+
await recordJournalResult(rc, label, journalKey, cached.value);
|
|
369
|
+
return cached.value;
|
|
370
|
+
}
|
|
371
|
+
// Stop spending the moment the run is over budget — before queueing.
|
|
372
|
+
ensureWithinBudget(rc.budget);
|
|
191
373
|
// Track queued agents before acquiring a global concurrency slot.
|
|
192
374
|
const rowId = rc.progress.agentQueued(opts.phase, label);
|
|
193
375
|
let failureHandled = false;
|
|
@@ -195,6 +377,8 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
195
377
|
return await rc.semaphore.run(
|
|
196
378
|
async () => {
|
|
197
379
|
throwIfAborted(rc.signal);
|
|
380
|
+
// Re-check after waiting for a slot; earlier agents may have exhausted the budget.
|
|
381
|
+
ensureWithinBudget(rc.budget);
|
|
198
382
|
rc.progress.agentStart(opts.phase, label, rowId);
|
|
199
383
|
|
|
200
384
|
let captured: unknown = null;
|
|
@@ -202,6 +386,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
202
386
|
let session: AgentRunnerSession | undefined;
|
|
203
387
|
let usageRecorded = false;
|
|
204
388
|
let unsubscribe: (() => void) | undefined;
|
|
389
|
+
let workspace: AgentWorkspace | undefined;
|
|
205
390
|
const recordUsage = (activeSession: AgentRunnerSession): void => {
|
|
206
391
|
if (usageRecorded) return;
|
|
207
392
|
usageRecorded = true;
|
|
@@ -224,13 +409,16 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
224
409
|
: [];
|
|
225
410
|
|
|
226
411
|
try {
|
|
412
|
+
workspace = await createAgentWorkspace(rc, opts, label);
|
|
413
|
+
const agentCwd = workspace.cwd;
|
|
414
|
+
|
|
227
415
|
const { model } = resolveAgentModel(opts.model, rc.modelRegistry, rc.hostModel);
|
|
228
416
|
const skillSetup = shouldCreateSkillResourceLoader(rc, prompt, opts)
|
|
229
417
|
? await rc.perf.time(
|
|
230
418
|
"agent.skills_ms",
|
|
231
419
|
() =>
|
|
232
420
|
createAgentSkillResourceLoader({
|
|
233
|
-
cwd:
|
|
421
|
+
cwd: agentCwd,
|
|
234
422
|
prompt,
|
|
235
423
|
skills: opts.skills,
|
|
236
424
|
log: (message) => rc.progress.log(`${label}: ${message}`),
|
|
@@ -239,30 +427,44 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
239
427
|
)
|
|
240
428
|
: undefined;
|
|
241
429
|
const selectedSkills = skillSetup?.selectedSkills ?? [];
|
|
242
|
-
// When the workflow restricts
|
|
430
|
+
// When the workflow restricts tools, keep the terminating tool visible.
|
|
243
431
|
// Skill opt-ins also need read so the subagent can load SKILL.md on demand.
|
|
244
|
-
const
|
|
432
|
+
const toolSelection = buildToolSelection(opts, selectedSkills.length > 0);
|
|
245
433
|
|
|
246
434
|
const createSessionForRun = rc.createSession ?? defaultCreateSession;
|
|
435
|
+
const createSubagentSession = (sessionOptions: ToolSessionOptions) =>
|
|
436
|
+
rc.perf.time(
|
|
437
|
+
"agent.create_session_ms",
|
|
438
|
+
() =>
|
|
439
|
+
createSessionForRun({
|
|
440
|
+
cwd: agentCwd,
|
|
441
|
+
model,
|
|
442
|
+
modelRegistry: rc.modelRegistry as ModelRegistry,
|
|
443
|
+
thinkingLevel: opts.thinkingLevel,
|
|
444
|
+
tools: sessionOptions.tools,
|
|
445
|
+
excludeTools: sessionOptions.excludeTools,
|
|
446
|
+
customTools,
|
|
447
|
+
resourceLoader: skillSetup?.resourceLoader,
|
|
448
|
+
sessionManager: SessionManager.inMemory(agentCwd),
|
|
449
|
+
}),
|
|
450
|
+
tags,
|
|
451
|
+
);
|
|
247
452
|
|
|
248
453
|
throwIfAborted(rc.signal);
|
|
249
|
-
|
|
250
|
-
"agent.create_session_ms",
|
|
251
|
-
() =>
|
|
252
|
-
createSessionForRun({
|
|
253
|
-
cwd: rc.cwd,
|
|
254
|
-
model,
|
|
255
|
-
modelRegistry: rc.modelRegistry as ModelRegistry,
|
|
256
|
-
thinkingLevel: opts.thinkingLevel,
|
|
257
|
-
tools: allow,
|
|
258
|
-
customTools,
|
|
259
|
-
resourceLoader: skillSetup?.resourceLoader,
|
|
260
|
-
sessionManager: SessionManager.inMemory(rc.cwd),
|
|
261
|
-
}),
|
|
262
|
-
tags,
|
|
263
|
-
);
|
|
454
|
+
let created = await createSubagentSession(toolSelection.sessionOptions);
|
|
264
455
|
session = created.session;
|
|
265
|
-
|
|
456
|
+
let activeSession = session;
|
|
457
|
+
if (!applyDynamicToolHints(activeSession, toolSelection)) {
|
|
458
|
+
rc.progress.log(`${label}: dynamic tool hints unavailable; falling back to concrete tools only`);
|
|
459
|
+
rc.perf.counter("agent.tool_hint_fallback", 1, tags);
|
|
460
|
+
const dynamicSession = activeSession;
|
|
461
|
+
session = undefined;
|
|
462
|
+
rc.perf.timeSync("agent.dispose_ms", () => dynamicSession.dispose(), tags);
|
|
463
|
+
throwIfAborted(rc.signal);
|
|
464
|
+
created = await createSubagentSession(toolSelection.fallbackSessionOptions);
|
|
465
|
+
session = created.session;
|
|
466
|
+
activeSession = session;
|
|
467
|
+
}
|
|
266
468
|
throwIfAborted(rc.signal);
|
|
267
469
|
|
|
268
470
|
unsubscribe = activeSession.subscribe((event) => {
|
|
@@ -279,13 +481,21 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
279
481
|
const unlinkPromptAbort = linkSessionAbort(rc.signal, activeSession);
|
|
280
482
|
try {
|
|
281
483
|
await rc.perf.time("agent.prompt_ms", () => activeSession.prompt(finalPrompt), tags);
|
|
484
|
+
if (opts.schema) {
|
|
485
|
+
for (let attempt = 0; captured === null && attempt < SCHEMA_REPROMPT_ATTEMPTS; attempt++) {
|
|
486
|
+
throwIfAborted(rc.signal);
|
|
487
|
+
rc.progress.log(`${label}: no final answer; re-prompting (${attempt + 1}/${SCHEMA_REPROMPT_ATTEMPTS})`);
|
|
488
|
+
rc.perf.counter("agent.structured_reprompt", 1, tags);
|
|
489
|
+
await rc.perf.time("agent.prompt_ms", () => activeSession.prompt(SCHEMA_REPROMPT), tags);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
282
492
|
} finally {
|
|
283
493
|
unlinkPromptAbort();
|
|
284
494
|
}
|
|
285
495
|
recordUsage(activeSession);
|
|
286
496
|
throwIfAborted(rc.signal);
|
|
287
497
|
|
|
288
|
-
|
|
498
|
+
const rawResult = rc.perf.timeSync(
|
|
289
499
|
"agent.extract_result_ms",
|
|
290
500
|
() => {
|
|
291
501
|
if (opts.schema) {
|
|
@@ -299,6 +509,9 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
299
509
|
},
|
|
300
510
|
tags,
|
|
301
511
|
);
|
|
512
|
+
const result = await workspace.wrapResult(rawResult);
|
|
513
|
+
await recordJournalResult(rc, label, journalKey, result);
|
|
514
|
+
return result;
|
|
302
515
|
} catch (error) {
|
|
303
516
|
failed = true;
|
|
304
517
|
failureHandled = true;
|
|
@@ -318,6 +531,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
318
531
|
tags,
|
|
319
532
|
);
|
|
320
533
|
}
|
|
534
|
+
await disposeAgentWorkspace(rc, label, workspace);
|
|
321
535
|
if (!failed) rc.progress.agentDone(label, rowId);
|
|
322
536
|
}
|
|
323
537
|
},
|
|
@@ -332,3 +546,13 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
332
546
|
}
|
|
333
547
|
}, tags);
|
|
334
548
|
}
|
|
549
|
+
|
|
550
|
+
async function isolatedResult(rc: RunContext, worktreePath: string, result: unknown): Promise<{ readonly result: unknown; readonly patch: string; readonly changed: boolean }> {
|
|
551
|
+
const patch = await rc.worktrees.capturePatch(worktreePath, rc.signal);
|
|
552
|
+
if ("error" in patch) throw new Error(`Failed to capture isolated worktree patch: ${patch.error}`);
|
|
553
|
+
return { result, patch: patch.patch, changed: patch.changed };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function errorMessage(error: unknown): string {
|
|
557
|
+
return error instanceof Error ? error.message : String(error);
|
|
558
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { WorkflowUsageSink } from "./usage.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thrown by `agent()` when a run has consumed its token budget. Carries the
|
|
5
|
+
* configured ceiling and the output tokens spent so callers can report both.
|
|
6
|
+
*/
|
|
7
|
+
export class WorkflowBudgetExceededError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
readonly total: number,
|
|
10
|
+
readonly spent: number,
|
|
11
|
+
) {
|
|
12
|
+
super(`Workflow token budget exhausted: spent ${spent} output tokens of ${total}.`);
|
|
13
|
+
this.name = "WorkflowBudgetExceededError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The budget handle exposed on `WorkflowApi`. Mirrors the built-in Workflow tool:
|
|
19
|
+
* `total` is the output-token ceiling (or `null` when unset), `spent()` is the
|
|
20
|
+
* output tokens consumed by completed agents this run, and `remaining()` is the
|
|
21
|
+
* headroom left (`Infinity` when there is no ceiling).
|
|
22
|
+
*
|
|
23
|
+
* `spent()`/`remaining()` are LIVE — they re-read usage on every call so that
|
|
24
|
+
* `while (budget.total && budget.remaining() > N) { await agent(...) }` loops and
|
|
25
|
+
* `Math.floor(budget.total / N)` fleet-scaling see up-to-date numbers.
|
|
26
|
+
*/
|
|
27
|
+
export interface WorkflowBudget {
|
|
28
|
+
readonly total: number | null;
|
|
29
|
+
/** Output tokens consumed by completed agents this run (sub-workflows included). */
|
|
30
|
+
spent(): number;
|
|
31
|
+
/** `total - spent()` clamped at 0, or `Infinity` when `total` is null. */
|
|
32
|
+
remaining(): number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a budget backed by the run's usage sink. `spent()` reads
|
|
37
|
+
* `usage.snapshot().totals.output` — the same output-token sum surfaced in the
|
|
38
|
+
* usage line — so no separate token accounting is needed.
|
|
39
|
+
*/
|
|
40
|
+
export function createBudget(total: number | null, usage: WorkflowUsageSink): WorkflowBudget {
|
|
41
|
+
return {
|
|
42
|
+
total,
|
|
43
|
+
spent: () => usage.snapshot().totals.output,
|
|
44
|
+
remaining: () => (total === null ? Infinity : Math.max(0, total - usage.snapshot().totals.output)),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -16,6 +16,13 @@ export function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
|
16
16
|
if (signal?.aborted) throw abortReason(signal);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function isFatalWorkflowError(error: unknown, signal: AbortSignal | undefined): boolean {
|
|
20
|
+
if (signal?.aborted) return true;
|
|
21
|
+
if (error instanceof WorkflowAbortError) return true;
|
|
22
|
+
if (typeof DOMException !== "undefined" && error instanceof DOMException && error.name === "AbortError") return true;
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
export function linkAbortSignal(parent: AbortSignal | undefined, controller: AbortController): () => void {
|
|
20
27
|
if (!parent) return () => {};
|
|
21
28
|
if (parent.aborted) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { performance } from "node:perf_hooks";
|
|
2
|
-
import { abortReason, throwIfAborted } from "./cancellation.ts";
|
|
2
|
+
import { abortReason, isFatalWorkflowError, throwIfAborted } from "./cancellation.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A counting semaphore: bounds how many async tasks run at once.
|
|
@@ -68,15 +68,24 @@ export interface ParallelOptions {
|
|
|
68
68
|
readonly limit?: number;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
export interface PipelineOptions {
|
|
72
|
+
readonly signal?: AbortSignal;
|
|
73
|
+
readonly abortController?: AbortController;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function compactResults<T>(values: ReadonlyArray<T | null | undefined>): T[] {
|
|
77
|
+
return values.filter((value): value is T => value != null);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Run every thunk concurrently and wait for all results; recoverable failures become null slots. */
|
|
81
|
+
export async function parallel<T>(thunks: Array<() => Promise<T>>, options: ParallelOptions = {}): Promise<Array<T | null>> {
|
|
73
82
|
throwIfAborted(options.signal);
|
|
74
|
-
const results = new Array<T>(thunks.length);
|
|
83
|
+
const results = new Array<T | null>(thunks.length);
|
|
75
84
|
let remaining = thunks.length;
|
|
76
85
|
if (remaining === 0) return results;
|
|
77
86
|
|
|
78
87
|
const limit = normalizeLimit(options.limit, thunks.length);
|
|
79
|
-
return await new Promise<T
|
|
88
|
+
return await new Promise<Array<T | null>>((resolve, reject) => {
|
|
80
89
|
let rejected = false;
|
|
81
90
|
let active = 0;
|
|
82
91
|
let next = 0;
|
|
@@ -88,6 +97,18 @@ export async function parallel<T>(thunks: Array<() => Promise<T>>, options: Para
|
|
|
88
97
|
cleanup();
|
|
89
98
|
reject(error);
|
|
90
99
|
};
|
|
100
|
+
const settleSlot = (index: number, value: T | null) => {
|
|
101
|
+
if (rejected) return;
|
|
102
|
+
active--;
|
|
103
|
+
results[index] = value;
|
|
104
|
+
remaining--;
|
|
105
|
+
if (remaining === 0) {
|
|
106
|
+
cleanup();
|
|
107
|
+
resolve(results);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
launchMore();
|
|
111
|
+
};
|
|
91
112
|
const onAbort = () => rejectOnce(abortReason(options.signal));
|
|
92
113
|
const launchMore = () => {
|
|
93
114
|
while (!rejected && active < limit && next < thunks.length) {
|
|
@@ -99,19 +120,14 @@ export async function parallel<T>(thunks: Array<() => Promise<T>>, options: Para
|
|
|
99
120
|
return thunks[index]();
|
|
100
121
|
})
|
|
101
122
|
.then(
|
|
102
|
-
(value) =>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
remaining--;
|
|
107
|
-
if (remaining === 0) {
|
|
108
|
-
cleanup();
|
|
109
|
-
resolve(results);
|
|
123
|
+
(value) => settleSlot(index, value),
|
|
124
|
+
(error: unknown) => {
|
|
125
|
+
if (isFatalWorkflowError(error, options.signal)) {
|
|
126
|
+
rejectOnce(error);
|
|
110
127
|
return;
|
|
111
128
|
}
|
|
112
|
-
|
|
129
|
+
settleSlot(index, null);
|
|
113
130
|
},
|
|
114
|
-
(error: unknown) => rejectOnce(error),
|
|
115
131
|
);
|
|
116
132
|
}
|
|
117
133
|
};
|
|
@@ -137,33 +153,77 @@ function normalizeLimit(limit: number | undefined, itemCount: number): number {
|
|
|
137
153
|
export async function pipeline<Item, A>(
|
|
138
154
|
items: readonly Item[],
|
|
139
155
|
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
140
|
-
): Promise<A
|
|
156
|
+
): Promise<Array<A | null>>;
|
|
141
157
|
export async function pipeline<Item, A, B>(
|
|
142
158
|
items: readonly Item[],
|
|
143
159
|
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
144
160
|
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
145
|
-
): Promise<B
|
|
161
|
+
): Promise<Array<B | null>>;
|
|
146
162
|
export async function pipeline<Item, A, B, C>(
|
|
147
163
|
items: readonly Item[],
|
|
148
164
|
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
149
165
|
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
150
166
|
stage3: (prev: B, item: Item, index: number) => Promise<C>,
|
|
151
|
-
): Promise<C
|
|
167
|
+
): Promise<Array<C | null>>;
|
|
152
168
|
export async function pipeline(
|
|
153
169
|
items: readonly unknown[],
|
|
154
170
|
...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
|
|
155
|
-
): Promise<unknown
|
|
171
|
+
): Promise<Array<unknown | null>>;
|
|
156
172
|
export async function pipeline(
|
|
157
173
|
items: readonly unknown[],
|
|
158
174
|
...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
|
|
159
|
-
): Promise<unknown
|
|
175
|
+
): Promise<Array<unknown | null>> {
|
|
176
|
+
return pipelineWithOptions(items, stages);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function bindPipeline(options: PipelineOptions): Pipeline {
|
|
180
|
+
async function boundPipeline<Item, A>(
|
|
181
|
+
items: readonly Item[],
|
|
182
|
+
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
183
|
+
): Promise<Array<A | null>>;
|
|
184
|
+
async function boundPipeline<Item, A, B>(
|
|
185
|
+
items: readonly Item[],
|
|
186
|
+
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
187
|
+
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
188
|
+
): Promise<Array<B | null>>;
|
|
189
|
+
async function boundPipeline<Item, A, B, C>(
|
|
190
|
+
items: readonly Item[],
|
|
191
|
+
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
192
|
+
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
193
|
+
stage3: (prev: B, item: Item, index: number) => Promise<C>,
|
|
194
|
+
): Promise<Array<C | null>>;
|
|
195
|
+
async function boundPipeline(
|
|
196
|
+
items: readonly unknown[],
|
|
197
|
+
...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
|
|
198
|
+
): Promise<Array<unknown | null>> {
|
|
199
|
+
return pipelineWithOptions(items, stages, options);
|
|
200
|
+
}
|
|
201
|
+
return boundPipeline;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function pipelineWithOptions(
|
|
205
|
+
items: readonly unknown[],
|
|
206
|
+
stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>,
|
|
207
|
+
options: PipelineOptions = {},
|
|
208
|
+
): Promise<Array<unknown | null>> {
|
|
209
|
+
throwIfAborted(options.signal);
|
|
160
210
|
return Promise.all(
|
|
161
211
|
items.map(async (item, index) => {
|
|
212
|
+
throwIfAborted(options.signal);
|
|
162
213
|
let acc: unknown = item;
|
|
163
|
-
|
|
164
|
-
|
|
214
|
+
try {
|
|
215
|
+
for (const stage of stages) {
|
|
216
|
+
throwIfAborted(options.signal);
|
|
217
|
+
acc = await stage(acc, item, index);
|
|
218
|
+
}
|
|
219
|
+
return acc;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (isFatalWorkflowError(error, options.signal)) {
|
|
222
|
+
options.abortController?.abort(error);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
165
226
|
}
|
|
166
|
-
return acc;
|
|
167
227
|
}),
|
|
168
228
|
);
|
|
169
229
|
}
|