@quintinshaw/pi-dynamic-workflows 1.3.0 → 1.5.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/README.md +6 -3
- package/dist/agent.d.ts +2 -0
- package/dist/agent.js +6 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/run-persistence.d.ts +6 -0
- package/dist/workflow-manager.d.ts +8 -2
- package/dist/workflow-manager.js +50 -6
- package/dist/workflow.d.ts +14 -0
- package/dist/workflow.js +46 -2
- package/dist/worktree.d.ts +25 -0
- package/dist/worktree.js +61 -0
- package/package.json +1 -1
- package/src/agent.ts +8 -2
- package/src/index.ts +3 -0
- package/src/run-persistence.ts +2 -0
- package/src/workflow-manager.ts +62 -7
- package/src/workflow.ts +69 -3
- package/src/worktree.ts +76 -0
package/README.md
CHANGED
|
@@ -99,6 +99,7 @@ return { inventory, summary }
|
|
|
99
99
|
| `phase` | string | Override the current phase for this agent |
|
|
100
100
|
| `schema` | object | JSON Schema for structured output |
|
|
101
101
|
| `model` | string | Run this agent on a specific model — `provider/modelId` or a bare `modelId` |
|
|
102
|
+
| `isolation` | `"worktree"` | Run this agent in its own throwaway git worktree (parallel edits without conflict) |
|
|
102
103
|
| `timeoutMs` | number | Override the default 5-minute agent timeout |
|
|
103
104
|
|
|
104
105
|
Models can also be set per phase via `meta.phases[].model`. Precedence is `opts.model` > phase model > session default; an unknown model logs a warning and falls back to the default.
|
|
@@ -133,7 +134,9 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
|
|
|
133
134
|
- **Structured output** — JSON-Schema-validated subagent results
|
|
134
135
|
- **Real token & cost accounting** — read from each subagent's SDK session (input / output / total / cost), with a character estimate only as fallback when a provider reports no usage; `budget` gates on the real total
|
|
135
136
|
- **Real per-agent / per-phase model routing** — `opts.model` and `meta.phases[].model` actually select the model (resolved against your authed model registry), with graceful fallback
|
|
136
|
-
- **`/workflows` command** — list, inspect, stop, pause, and remove background runs; runs started with `background: true` are reachable from the command
|
|
137
|
+
- **`/workflows` command** — list, inspect, stop, pause, **resume**, and remove background runs; runs started with `background: true` are reachable from the command
|
|
138
|
+
- **Resume** — each agent result is journaled by a deterministic call index; resuming replays the unchanged prefix from cache (no re-run, no tokens) and runs only new or edited calls live
|
|
139
|
+
- **Worktree isolation** — `isolation: "worktree"` runs an agent in its own git worktree on a throwaway branch, so parallel agents can edit the same files without conflict; the worktree is torn down after (results are not auto-merged), and it falls back to a logged no-op outside a git repo
|
|
137
140
|
- **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
|
|
138
141
|
- **Live progress + token/cost display**, `Esc` to abort
|
|
139
142
|
- **Log persistence** to `.pi/workflows/runs/`
|
|
@@ -142,9 +145,9 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
|
|
|
142
145
|
|
|
143
146
|
Tracked toward closer parity with Claude Code dynamic workflows:
|
|
144
147
|
|
|
145
|
-
- **
|
|
146
|
-
- **Worktree isolation** for parallel edits, and **bundled `/deep-research`**
|
|
148
|
+
- **Bundled `/deep-research`** and `/adversarial-review` workflows
|
|
147
149
|
- **Saved workflows** as `/<name>` slash commands
|
|
150
|
+
- **Nested `workflow()`** to compose saved workflows inline
|
|
148
151
|
|
|
149
152
|
## How it works
|
|
150
153
|
|
package/dist/agent.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefi
|
|
|
38
38
|
model?: string;
|
|
39
39
|
/** Called with the resolved model id once known (for display/telemetry). */
|
|
40
40
|
onModelResolved?: (modelId: string) => void;
|
|
41
|
+
/** Run this agent in a different working directory (e.g. an isolated worktree). */
|
|
42
|
+
cwd?: string;
|
|
41
43
|
}
|
|
42
44
|
export type AgentRunResult<TSchemaDef extends TSchema | undefined> = TSchemaDef extends TSchema ? Static<TSchemaDef> : string;
|
|
43
45
|
export declare class WorkflowAgent {
|
package/dist/agent.js
CHANGED
|
@@ -39,7 +39,11 @@ export class WorkflowAgent {
|
|
|
39
39
|
}
|
|
40
40
|
async run(prompt, options = {}) {
|
|
41
41
|
const capture = { called: false, value: undefined };
|
|
42
|
-
|
|
42
|
+
// Per-call cwd (e.g. a worktree) needs coding tools bound to that directory,
|
|
43
|
+
// since tools capture their cwd at construction and can't be relocated.
|
|
44
|
+
const runCwd = options.cwd ?? this.cwd;
|
|
45
|
+
const baseTools = runCwd === this.cwd ? this.baseTools : createCodingTools(runCwd);
|
|
46
|
+
const customTools = [...baseTools, ...(options.tools ?? [])];
|
|
43
47
|
if (options.schema) {
|
|
44
48
|
customTools.push(createStructuredOutputTool({ schema: options.schema, capture }));
|
|
45
49
|
}
|
|
@@ -57,7 +61,7 @@ export class WorkflowAgent {
|
|
|
57
61
|
}
|
|
58
62
|
const agentDir = getAgentDir();
|
|
59
63
|
const { session } = await createAgentSession({
|
|
60
|
-
cwd:
|
|
64
|
+
cwd: runCwd,
|
|
61
65
|
agentDir,
|
|
62
66
|
sessionManager: SessionManager.inMemory(),
|
|
63
67
|
// Use real SettingsManager to inherit user's default provider/model settings.
|
package/dist/index.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export type { PersistedRunState, RunPersistence, RunStatus } from "./run-persist
|
|
|
18
18
|
export { createRunPersistence, generateRunId } from "./run-persistence.js";
|
|
19
19
|
export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./structured-output.js";
|
|
20
20
|
export { createStructuredOutputTool } from "./structured-output.js";
|
|
21
|
-
export type { AgentOptions, WorkflowMeta, WorkflowMetaPhase, WorkflowRunOptions, WorkflowRunResult, } from "./workflow.js";
|
|
21
|
+
export type { AgentOptions, JournalEntry, WorkflowMeta, WorkflowMetaPhase, WorkflowRunOptions, WorkflowRunResult, } from "./workflow.js";
|
|
22
22
|
export { parseWorkflowScript, runWorkflow } from "./workflow.js";
|
|
23
23
|
export { registerWorkflowCommands } from "./workflow-commands.js";
|
|
24
24
|
export type { ManagedRun, WorkflowManagerOptions } from "./workflow-manager.js";
|
|
@@ -27,3 +27,5 @@ export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
|
|
|
27
27
|
export { createWorkflowStorage } from "./workflow-saved.js";
|
|
28
28
|
export type { WorkflowToolInput, WorkflowToolOptions } from "./workflow-tool.js";
|
|
29
29
|
export { createWorkflowTool } from "./workflow-tool.js";
|
|
30
|
+
export type { Worktree } from "./worktree.js";
|
|
31
|
+
export { createWorktree, removeWorktree } from "./worktree.js";
|
package/dist/index.js
CHANGED
|
@@ -14,3 +14,4 @@ export { registerWorkflowCommands } from "./workflow-commands.js";
|
|
|
14
14
|
export { WorkflowManager } from "./workflow-manager.js";
|
|
15
15
|
export { createWorkflowStorage } from "./workflow-saved.js";
|
|
16
16
|
export { createWorkflowTool } from "./workflow-tool.js";
|
|
17
|
+
export { createWorktree, removeWorktree } from "./worktree.js";
|
|
@@ -33,6 +33,12 @@ export interface PersistedRunState {
|
|
|
33
33
|
output: number;
|
|
34
34
|
total: number;
|
|
35
35
|
};
|
|
36
|
+
/** Cached agent results for resume, keyed by deterministic call index. */
|
|
37
|
+
journal?: Array<{
|
|
38
|
+
index: number;
|
|
39
|
+
hash: string;
|
|
40
|
+
result: unknown;
|
|
41
|
+
}>;
|
|
36
42
|
}
|
|
37
43
|
export interface RunPersistence {
|
|
38
44
|
/** Save current run state. */
|
|
@@ -5,7 +5,7 @@ import { EventEmitter } from "node:events";
|
|
|
5
5
|
import type { WorkflowSnapshot } from "./display.js";
|
|
6
6
|
import { WorkflowError } from "./errors.js";
|
|
7
7
|
import { type PersistedRunState, type RunPersistence, type RunStatus } from "./run-persistence.js";
|
|
8
|
-
import { type WorkflowRunResult } from "./workflow.js";
|
|
8
|
+
import { type JournalEntry, type WorkflowRunResult } from "./workflow.js";
|
|
9
9
|
export interface ManagedRun {
|
|
10
10
|
runId: string;
|
|
11
11
|
status: RunStatus;
|
|
@@ -14,6 +14,11 @@ export interface ManagedRun {
|
|
|
14
14
|
error?: WorkflowError;
|
|
15
15
|
controller: AbortController;
|
|
16
16
|
startedAt: Date;
|
|
17
|
+
/** The real script, kept so the run can be resumed. */
|
|
18
|
+
script: string;
|
|
19
|
+
args?: unknown;
|
|
20
|
+
/** Accumulated agent results for resume (deterministic call index -> result). */
|
|
21
|
+
journal: JournalEntry[];
|
|
17
22
|
}
|
|
18
23
|
export interface WorkflowManagerOptions {
|
|
19
24
|
cwd?: string;
|
|
@@ -44,7 +49,8 @@ export declare class WorkflowManager extends EventEmitter {
|
|
|
44
49
|
*/
|
|
45
50
|
pause(runId: string): boolean;
|
|
46
51
|
/**
|
|
47
|
-
* Resume
|
|
52
|
+
* Resume an interrupted run: replay journaled results for the unchanged prefix
|
|
53
|
+
* and run the rest live. Returns false if there is nothing resumable.
|
|
48
54
|
*/
|
|
49
55
|
resume(runId: string): Promise<boolean>;
|
|
50
56
|
/**
|
package/dist/workflow-manager.js
CHANGED
|
@@ -40,6 +40,9 @@ export class WorkflowManager extends EventEmitter {
|
|
|
40
40
|
},
|
|
41
41
|
controller,
|
|
42
42
|
startedAt: new Date(),
|
|
43
|
+
script,
|
|
44
|
+
args,
|
|
45
|
+
journal: [],
|
|
43
46
|
};
|
|
44
47
|
this.runs.set(runId, managed);
|
|
45
48
|
// Persist initial state
|
|
@@ -82,17 +85,28 @@ export class WorkflowManager extends EventEmitter {
|
|
|
82
85
|
},
|
|
83
86
|
controller,
|
|
84
87
|
startedAt: new Date(),
|
|
88
|
+
script,
|
|
89
|
+
args,
|
|
90
|
+
journal: [],
|
|
85
91
|
};
|
|
86
92
|
this.runs.set(runId, managed);
|
|
87
93
|
return this.executeRun(managed, script, args);
|
|
88
94
|
}
|
|
89
|
-
async executeRun(managed, script, args) {
|
|
95
|
+
async executeRun(managed, script, args, resumeJournal) {
|
|
90
96
|
try {
|
|
91
97
|
const result = await runWorkflow(script, {
|
|
92
98
|
cwd: this.cwd,
|
|
93
99
|
args,
|
|
94
100
|
signal: managed.controller.signal,
|
|
95
101
|
concurrency: this.concurrency,
|
|
102
|
+
resumeJournal,
|
|
103
|
+
resumeFromRunId: resumeJournal ? managed.runId : undefined,
|
|
104
|
+
onAgentJournal: (entry) => {
|
|
105
|
+
// Append (crash-safe-ish): keep the latest entry per index, then persist.
|
|
106
|
+
managed.journal = managed.journal.filter((e) => e.index !== entry.index);
|
|
107
|
+
managed.journal.push(entry);
|
|
108
|
+
this.persistRun(managed);
|
|
109
|
+
},
|
|
96
110
|
onLog: (message) => {
|
|
97
111
|
managed.snapshot.logs.push(message);
|
|
98
112
|
this.emit("log", { runId: managed.runId, message });
|
|
@@ -152,7 +166,11 @@ export class WorkflowManager extends EventEmitter {
|
|
|
152
166
|
this.persistence.save({
|
|
153
167
|
runId: managed.runId,
|
|
154
168
|
workflowName: managed.snapshot.name,
|
|
155
|
-
script
|
|
169
|
+
// Persist the real script + journal so the run can be resumed. Runs live
|
|
170
|
+
// under .pi/workflows/runs/ — protect via directory permissions, not blanking.
|
|
171
|
+
script: managed.script,
|
|
172
|
+
args: managed.args,
|
|
173
|
+
journal: managed.journal,
|
|
156
174
|
status: managed.status,
|
|
157
175
|
phases: managed.snapshot.phases,
|
|
158
176
|
currentPhase: managed.snapshot.currentPhase,
|
|
@@ -183,15 +201,41 @@ export class WorkflowManager extends EventEmitter {
|
|
|
183
201
|
return true;
|
|
184
202
|
}
|
|
185
203
|
/**
|
|
186
|
-
* Resume
|
|
204
|
+
* Resume an interrupted run: replay journaled results for the unchanged prefix
|
|
205
|
+
* and run the rest live. Returns false if there is nothing resumable.
|
|
187
206
|
*/
|
|
188
207
|
async resume(runId) {
|
|
208
|
+
const active = this.runs.get(runId);
|
|
209
|
+
if (active?.status === "running")
|
|
210
|
+
return false; // already running
|
|
189
211
|
const persisted = this.persistence.load(runId);
|
|
190
|
-
if (persisted?.status
|
|
212
|
+
if (!persisted?.script || persisted.status === "completed")
|
|
191
213
|
return false;
|
|
192
|
-
|
|
193
|
-
|
|
214
|
+
const controller = new AbortController();
|
|
215
|
+
const managed = {
|
|
216
|
+
runId,
|
|
217
|
+
status: "running",
|
|
218
|
+
snapshot: {
|
|
219
|
+
name: persisted.workflowName,
|
|
220
|
+
phases: persisted.phases ?? [],
|
|
221
|
+
logs: persisted.logs ?? [],
|
|
222
|
+
agents: [],
|
|
223
|
+
agentCount: 0,
|
|
224
|
+
runningCount: 0,
|
|
225
|
+
doneCount: 0,
|
|
226
|
+
errorCount: 0,
|
|
227
|
+
},
|
|
228
|
+
controller,
|
|
229
|
+
startedAt: new Date(),
|
|
230
|
+
script: persisted.script,
|
|
231
|
+
args: persisted.args,
|
|
232
|
+
journal: persisted.journal ?? [],
|
|
233
|
+
};
|
|
234
|
+
this.runs.set(runId, managed);
|
|
235
|
+
const resumeJournal = new Map((persisted.journal ?? []).map((e) => [e.index, e]));
|
|
194
236
|
this.emit("resumed", { runId });
|
|
237
|
+
// Run in the background; executeRun records status/errors on the managed run.
|
|
238
|
+
void this.executeRun(managed, persisted.script, persisted.args, resumeJournal).catch(() => { });
|
|
195
239
|
return true;
|
|
196
240
|
}
|
|
197
241
|
/**
|
package/dist/workflow.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ export interface WorkflowMeta {
|
|
|
11
11
|
whenToUse?: string;
|
|
12
12
|
phases?: WorkflowMetaPhase[];
|
|
13
13
|
}
|
|
14
|
+
/** One cached agent() result, keyed by its deterministic call index. */
|
|
15
|
+
export interface JournalEntry {
|
|
16
|
+
index: number;
|
|
17
|
+
/** sha256 of the call's identity (prompt + model + phase + agentType + schema). */
|
|
18
|
+
hash: string;
|
|
19
|
+
result: unknown;
|
|
20
|
+
}
|
|
14
21
|
export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
15
22
|
args?: unknown;
|
|
16
23
|
agent?: Pick<WorkflowAgent, "run">;
|
|
@@ -25,6 +32,12 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
|
25
32
|
persistLogs?: boolean;
|
|
26
33
|
/** Run ID for persistence. Auto-generated if not provided. */
|
|
27
34
|
runId?: string;
|
|
35
|
+
/** Resume: cached agent results keyed by deterministic call index. */
|
|
36
|
+
resumeJournal?: Map<number, JournalEntry>;
|
|
37
|
+
/** Resume: the run being resumed (informational; enables resume mode). */
|
|
38
|
+
resumeFromRunId?: string;
|
|
39
|
+
/** Called after each live agent completes so the caller can persist the journal. */
|
|
40
|
+
onAgentJournal?: (entry: JournalEntry) => void;
|
|
28
41
|
onLog?: (message: string) => void;
|
|
29
42
|
onPhase?: (title: string) => void;
|
|
30
43
|
onAgentStart?: (event: {
|
|
@@ -38,6 +51,7 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
|
38
51
|
phase?: string;
|
|
39
52
|
result: unknown;
|
|
40
53
|
tokens?: number;
|
|
54
|
+
worktree?: string;
|
|
41
55
|
}) => void;
|
|
42
56
|
onTokenUsage?: (usage: {
|
|
43
57
|
input: number;
|
package/dist/workflow.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import vm from "node:vm";
|
|
2
3
|
import { parse } from "acorn";
|
|
3
4
|
import { WorkflowAgent } from "./agent.js";
|
|
@@ -5,6 +6,7 @@ import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from ".
|
|
|
5
6
|
import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
6
7
|
import { createWorkflowLogger } from "./logger.js";
|
|
7
8
|
import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
|
|
9
|
+
import { createWorktree, removeWorktree } from "./worktree.js";
|
|
8
10
|
const DETERMINISM_BLOCKLIST = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
|
|
9
11
|
export async function runWorkflow(script, options = {}) {
|
|
10
12
|
const started = Date.now();
|
|
@@ -14,6 +16,7 @@ export async function runWorkflow(script, options = {}) {
|
|
|
14
16
|
const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
|
|
15
17
|
const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
|
|
16
18
|
const runId = options.runId ?? `run-${started.toString(36)}`;
|
|
19
|
+
const baseCwd = options.cwd ?? process.cwd();
|
|
17
20
|
// Initialize logger
|
|
18
21
|
const logger = createWorkflowLogger({
|
|
19
22
|
runId,
|
|
@@ -25,6 +28,7 @@ export async function runWorkflow(script, options = {}) {
|
|
|
25
28
|
logs: [],
|
|
26
29
|
phases: [],
|
|
27
30
|
agentCount: 0,
|
|
31
|
+
callSeq: 0,
|
|
28
32
|
spent: 0,
|
|
29
33
|
tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
|
|
30
34
|
};
|
|
@@ -67,11 +71,33 @@ export async function runWorkflow(script, options = {}) {
|
|
|
67
71
|
const requestedLabel = agentOptions.label?.trim();
|
|
68
72
|
// Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
|
|
69
73
|
const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
|
|
74
|
+
// Deterministic resume key: assigned at lexical call time, before the limiter,
|
|
75
|
+
// so parallel()/pipeline() fan-out is reproducible for a fixed script.
|
|
76
|
+
const callIndex = state.callSeq++;
|
|
77
|
+
const callHash = hashAgentCall(prompt, modelSpec, assignedPhase, agentOptions);
|
|
78
|
+
// Resume: replay a cached result for an unchanged call (matching hash), without
|
|
79
|
+
// consuming a concurrency slot, tokens, or a real subagent run.
|
|
80
|
+
const cached = options.resumeJournal?.get(callIndex);
|
|
81
|
+
if (cached && cached.hash === callHash) {
|
|
82
|
+
state.agentCount++;
|
|
83
|
+
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
84
|
+
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
|
|
85
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
|
|
86
|
+
return cached.result;
|
|
87
|
+
}
|
|
70
88
|
return limiter(async () => {
|
|
71
89
|
state.agentCount++;
|
|
72
90
|
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
73
91
|
const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
|
|
74
92
|
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
|
|
93
|
+
// Optional per-agent worktree isolation (deterministic name -> stable resume keys).
|
|
94
|
+
let worktree;
|
|
95
|
+
if (agentOptions.isolation === "worktree") {
|
|
96
|
+
worktree = await createWorktree(baseCwd, `${runId}-${callIndex}-${label}`);
|
|
97
|
+
if (!worktree.isolated)
|
|
98
|
+
log(`isolation ignored for "${label}" (${worktree.reason})`);
|
|
99
|
+
}
|
|
100
|
+
const runCwd = worktree?.isolated ? worktree.cwd : undefined;
|
|
75
101
|
// Captured from the subagent's real session usage; falls back to an
|
|
76
102
|
// estimate when the provider reports no usage (total === 0).
|
|
77
103
|
let usage;
|
|
@@ -95,13 +121,15 @@ export async function runWorkflow(script, options = {}) {
|
|
|
95
121
|
signal: options.signal,
|
|
96
122
|
instructions: buildAgentInstructions(assignedPhase, agentOptions),
|
|
97
123
|
model: modelSpec,
|
|
124
|
+
cwd: runCwd,
|
|
98
125
|
onUsage: (u) => {
|
|
99
126
|
usage = u;
|
|
100
127
|
},
|
|
101
128
|
}), timeout, `Agent "${label}" timed out after ${timeout}ms`);
|
|
102
129
|
throwIfAborted();
|
|
103
130
|
const tokens = recordTokens(result);
|
|
104
|
-
options.
|
|
131
|
+
options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
|
|
132
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd });
|
|
105
133
|
return result;
|
|
106
134
|
}
|
|
107
135
|
catch (error) {
|
|
@@ -110,13 +138,18 @@ export async function runWorkflow(script, options = {}) {
|
|
|
110
138
|
const workflowError = wrapError(error, { agentLabel: label });
|
|
111
139
|
logger.error(`agent ${label} failed: ${workflowError.message}`);
|
|
112
140
|
const tokens = recordTokens(null);
|
|
113
|
-
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens });
|
|
141
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens, worktree: runCwd });
|
|
114
142
|
// Return null for recoverable errors
|
|
115
143
|
if (workflowError.recoverable) {
|
|
116
144
|
return null;
|
|
117
145
|
}
|
|
118
146
|
throw workflowError;
|
|
119
147
|
}
|
|
148
|
+
finally {
|
|
149
|
+
// Always tear down the worktree, even on timeout/abort.
|
|
150
|
+
if (worktree?.isolated)
|
|
151
|
+
await removeWorktree(worktree);
|
|
152
|
+
}
|
|
120
153
|
});
|
|
121
154
|
};
|
|
122
155
|
const parallel = async (thunks) => {
|
|
@@ -348,6 +381,17 @@ function createLimiter(limit) {
|
|
|
348
381
|
function defaultAgentLabel(phase, index) {
|
|
349
382
|
return phase ? `${phase} agent ${index}` : `agent ${index}`;
|
|
350
383
|
}
|
|
384
|
+
/** Stable identity hash for an agent() call — a cache miss on resume when anything changes. */
|
|
385
|
+
function hashAgentCall(prompt, model, phase, options) {
|
|
386
|
+
const identity = JSON.stringify({
|
|
387
|
+
prompt,
|
|
388
|
+
model: model ?? null,
|
|
389
|
+
phase: phase ?? null,
|
|
390
|
+
agentType: options.agentType ?? null,
|
|
391
|
+
schema: options.schema ?? null,
|
|
392
|
+
});
|
|
393
|
+
return createHash("sha256").update(identity).digest("hex");
|
|
394
|
+
}
|
|
351
395
|
function buildAgentInstructions(phase, options) {
|
|
352
396
|
const lines = [];
|
|
353
397
|
if (phase)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent git worktree isolation. When an agent requests `isolation: "worktree"`,
|
|
3
|
+
* it runs in a throwaway worktree on its own branch so parallel agents can edit the
|
|
4
|
+
* same files without conflict. Results are NOT auto-merged — the path is surfaced for
|
|
5
|
+
* the caller to inspect. Falls back to a logged no-op when isolation isn't possible.
|
|
6
|
+
*/
|
|
7
|
+
export interface Worktree {
|
|
8
|
+
/** True when a real worktree was created; false means "ran in the shared tree". */
|
|
9
|
+
isolated: boolean;
|
|
10
|
+
/** cwd the agent should run in (worktree path when isolated, else the base cwd). */
|
|
11
|
+
cwd: string;
|
|
12
|
+
branch?: string;
|
|
13
|
+
/** Repo root the worktree was added to (for teardown). */
|
|
14
|
+
repoRoot?: string;
|
|
15
|
+
/** Why isolation was skipped, when isolated === false. */
|
|
16
|
+
reason?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create an isolated worktree under `<repoRoot>/.pi/worktrees/<name>` on branch
|
|
20
|
+
* `pi/wf/<name>`. The `name` must be deterministic (derived from runId + call index,
|
|
21
|
+
* never wall-clock) so resume keys stay stable. Returns a no-op Worktree on any failure.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createWorktree(baseCwd: string, name: string): Promise<Worktree>;
|
|
24
|
+
/** Remove a worktree and its branch. Best-effort; safe to call on a no-op Worktree. */
|
|
25
|
+
export declare function removeWorktree(wt: Worktree): Promise<void>;
|
package/dist/worktree.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent git worktree isolation. When an agent requests `isolation: "worktree"`,
|
|
3
|
+
* it runs in a throwaway worktree on its own branch so parallel agents can edit the
|
|
4
|
+
* same files without conflict. Results are NOT auto-merged — the path is surfaced for
|
|
5
|
+
* the caller to inspect. Falls back to a logged no-op when isolation isn't possible.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
function slug(name) {
|
|
12
|
+
return (name
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
15
|
+
.replace(/^-+|-+$/g, "")
|
|
16
|
+
.slice(0, 32) || "agent");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create an isolated worktree under `<repoRoot>/.pi/worktrees/<name>` on branch
|
|
20
|
+
* `pi/wf/<name>`. The `name` must be deterministic (derived from runId + call index,
|
|
21
|
+
* never wall-clock) so resume keys stay stable. Returns a no-op Worktree on any failure.
|
|
22
|
+
*/
|
|
23
|
+
export async function createWorktree(baseCwd, name) {
|
|
24
|
+
const id = slug(name);
|
|
25
|
+
let repoRoot;
|
|
26
|
+
try {
|
|
27
|
+
const { stdout } = await exec("git", ["-C", baseCwd, "rev-parse", "--show-toplevel"]);
|
|
28
|
+
repoRoot = stdout.trim();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { isolated: false, cwd: baseCwd, reason: "not a git repository" };
|
|
32
|
+
}
|
|
33
|
+
const path = join(repoRoot, ".pi", "worktrees", id);
|
|
34
|
+
const branch = `pi/wf/${id}`;
|
|
35
|
+
try {
|
|
36
|
+
await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
|
|
37
|
+
return { isolated: true, cwd: path, branch, repoRoot };
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return { isolated: false, cwd: baseCwd, reason: error instanceof Error ? error.message : String(error) };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Remove a worktree and its branch. Best-effort; safe to call on a no-op Worktree. */
|
|
44
|
+
export async function removeWorktree(wt) {
|
|
45
|
+
if (!wt.isolated || !wt.repoRoot)
|
|
46
|
+
return;
|
|
47
|
+
try {
|
|
48
|
+
await exec("git", ["-C", wt.repoRoot, "worktree", "remove", "--force", wt.cwd]);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// already gone / locked — fall through
|
|
52
|
+
}
|
|
53
|
+
if (wt.branch) {
|
|
54
|
+
try {
|
|
55
|
+
await exec("git", ["-C", wt.repoRoot, "branch", "-D", wt.branch]);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// branch already deleted
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
package/package.json
CHANGED
package/src/agent.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefi
|
|
|
54
54
|
model?: string;
|
|
55
55
|
/** Called with the resolved model id once known (for display/telemetry). */
|
|
56
56
|
onModelResolved?: (modelId: string) => void;
|
|
57
|
+
/** Run this agent in a different working directory (e.g. an isolated worktree). */
|
|
58
|
+
cwd?: string;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
export type AgentRunResult<TSchemaDef extends TSchema | undefined> = TSchemaDef extends TSchema
|
|
@@ -105,7 +107,11 @@ export class WorkflowAgent {
|
|
|
105
107
|
options: AgentRunOptions<TSchemaDef> = {},
|
|
106
108
|
): Promise<AgentRunResult<TSchemaDef>> {
|
|
107
109
|
const capture: StructuredOutputCapture<any> = { called: false, value: undefined };
|
|
108
|
-
|
|
110
|
+
// Per-call cwd (e.g. a worktree) needs coding tools bound to that directory,
|
|
111
|
+
// since tools capture their cwd at construction and can't be relocated.
|
|
112
|
+
const runCwd = options.cwd ?? this.cwd;
|
|
113
|
+
const baseTools = runCwd === this.cwd ? this.baseTools : createCodingTools(runCwd);
|
|
114
|
+
const customTools: ToolDefinition[] = [...baseTools, ...(options.tools ?? [])];
|
|
109
115
|
|
|
110
116
|
if (options.schema) {
|
|
111
117
|
customTools.push(createStructuredOutputTool({ schema: options.schema, capture }) as unknown as ToolDefinition);
|
|
@@ -125,7 +131,7 @@ export class WorkflowAgent {
|
|
|
125
131
|
|
|
126
132
|
const agentDir = getAgentDir();
|
|
127
133
|
const { session } = await createAgentSession({
|
|
128
|
-
cwd:
|
|
134
|
+
cwd: runCwd,
|
|
129
135
|
agentDir,
|
|
130
136
|
sessionManager: SessionManager.inMemory(),
|
|
131
137
|
// Use real SettingsManager to inherit user's default provider/model settings.
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./str
|
|
|
41
41
|
export { createStructuredOutputTool } from "./structured-output.js";
|
|
42
42
|
export type {
|
|
43
43
|
AgentOptions,
|
|
44
|
+
JournalEntry,
|
|
44
45
|
WorkflowMeta,
|
|
45
46
|
WorkflowMetaPhase,
|
|
46
47
|
WorkflowRunOptions,
|
|
@@ -54,3 +55,5 @@ export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
|
|
|
54
55
|
export { createWorkflowStorage } from "./workflow-saved.js";
|
|
55
56
|
export type { WorkflowToolInput, WorkflowToolOptions } from "./workflow-tool.js";
|
|
56
57
|
export { createWorkflowTool } from "./workflow-tool.js";
|
|
58
|
+
export type { Worktree } from "./worktree.js";
|
|
59
|
+
export { createWorktree, removeWorktree } from "./worktree.js";
|
package/src/run-persistence.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface PersistedRunState {
|
|
|
40
40
|
output: number;
|
|
41
41
|
total: number;
|
|
42
42
|
};
|
|
43
|
+
/** Cached agent results for resume, keyed by deterministic call index. */
|
|
44
|
+
journal?: Array<{ index: number; hash: string; result: unknown }>;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
export interface RunPersistence {
|
package/src/workflow-manager.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type RunPersistence,
|
|
13
13
|
type RunStatus,
|
|
14
14
|
} from "./run-persistence.js";
|
|
15
|
-
import { parseWorkflowScript, runWorkflow, type WorkflowRunResult } from "./workflow.js";
|
|
15
|
+
import { type JournalEntry, parseWorkflowScript, runWorkflow, type WorkflowRunResult } from "./workflow.js";
|
|
16
16
|
|
|
17
17
|
export interface ManagedRun {
|
|
18
18
|
runId: string;
|
|
@@ -22,6 +22,11 @@ export interface ManagedRun {
|
|
|
22
22
|
error?: WorkflowError;
|
|
23
23
|
controller: AbortController;
|
|
24
24
|
startedAt: Date;
|
|
25
|
+
/** The real script, kept so the run can be resumed. */
|
|
26
|
+
script: string;
|
|
27
|
+
args?: unknown;
|
|
28
|
+
/** Accumulated agent results for resume (deterministic call index -> result). */
|
|
29
|
+
journal: JournalEntry[];
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export interface WorkflowManagerOptions {
|
|
@@ -67,6 +72,9 @@ export class WorkflowManager extends EventEmitter {
|
|
|
67
72
|
},
|
|
68
73
|
controller,
|
|
69
74
|
startedAt: new Date(),
|
|
75
|
+
script,
|
|
76
|
+
args,
|
|
77
|
+
journal: [],
|
|
70
78
|
};
|
|
71
79
|
|
|
72
80
|
this.runs.set(runId, managed);
|
|
@@ -115,19 +123,35 @@ export class WorkflowManager extends EventEmitter {
|
|
|
115
123
|
},
|
|
116
124
|
controller,
|
|
117
125
|
startedAt: new Date(),
|
|
126
|
+
script,
|
|
127
|
+
args,
|
|
128
|
+
journal: [],
|
|
118
129
|
};
|
|
119
130
|
|
|
120
131
|
this.runs.set(runId, managed);
|
|
121
132
|
return this.executeRun(managed, script, args);
|
|
122
133
|
}
|
|
123
134
|
|
|
124
|
-
private async executeRun(
|
|
135
|
+
private async executeRun(
|
|
136
|
+
managed: ManagedRun,
|
|
137
|
+
script: string,
|
|
138
|
+
args?: unknown,
|
|
139
|
+
resumeJournal?: Map<number, JournalEntry>,
|
|
140
|
+
): Promise<WorkflowRunResult> {
|
|
125
141
|
try {
|
|
126
142
|
const result = await runWorkflow(script, {
|
|
127
143
|
cwd: this.cwd,
|
|
128
144
|
args,
|
|
129
145
|
signal: managed.controller.signal,
|
|
130
146
|
concurrency: this.concurrency,
|
|
147
|
+
resumeJournal,
|
|
148
|
+
resumeFromRunId: resumeJournal ? managed.runId : undefined,
|
|
149
|
+
onAgentJournal: (entry) => {
|
|
150
|
+
// Append (crash-safe-ish): keep the latest entry per index, then persist.
|
|
151
|
+
managed.journal = managed.journal.filter((e) => e.index !== entry.index);
|
|
152
|
+
managed.journal.push(entry);
|
|
153
|
+
this.persistRun(managed);
|
|
154
|
+
},
|
|
131
155
|
onLog: (message) => {
|
|
132
156
|
managed.snapshot.logs.push(message);
|
|
133
157
|
this.emit("log", { runId: managed.runId, message });
|
|
@@ -197,7 +221,11 @@ export class WorkflowManager extends EventEmitter {
|
|
|
197
221
|
this.persistence.save({
|
|
198
222
|
runId: managed.runId,
|
|
199
223
|
workflowName: managed.snapshot.name,
|
|
200
|
-
script
|
|
224
|
+
// Persist the real script + journal so the run can be resumed. Runs live
|
|
225
|
+
// under .pi/workflows/runs/ — protect via directory permissions, not blanking.
|
|
226
|
+
script: managed.script,
|
|
227
|
+
args: managed.args,
|
|
228
|
+
journal: managed.journal,
|
|
201
229
|
status: managed.status,
|
|
202
230
|
phases: managed.snapshot.phases,
|
|
203
231
|
currentPhase: managed.snapshot.currentPhase,
|
|
@@ -230,15 +258,42 @@ export class WorkflowManager extends EventEmitter {
|
|
|
230
258
|
}
|
|
231
259
|
|
|
232
260
|
/**
|
|
233
|
-
* Resume
|
|
261
|
+
* Resume an interrupted run: replay journaled results for the unchanged prefix
|
|
262
|
+
* and run the rest live. Returns false if there is nothing resumable.
|
|
234
263
|
*/
|
|
235
264
|
async resume(runId: string): Promise<boolean> {
|
|
265
|
+
const active = this.runs.get(runId);
|
|
266
|
+
if (active?.status === "running") return false; // already running
|
|
267
|
+
|
|
236
268
|
const persisted = this.persistence.load(runId);
|
|
237
|
-
if (persisted?.status
|
|
269
|
+
if (!persisted?.script || persisted.status === "completed") return false;
|
|
270
|
+
|
|
271
|
+
const controller = new AbortController();
|
|
272
|
+
const managed: ManagedRun = {
|
|
273
|
+
runId,
|
|
274
|
+
status: "running",
|
|
275
|
+
snapshot: {
|
|
276
|
+
name: persisted.workflowName,
|
|
277
|
+
phases: persisted.phases ?? [],
|
|
278
|
+
logs: persisted.logs ?? [],
|
|
279
|
+
agents: [],
|
|
280
|
+
agentCount: 0,
|
|
281
|
+
runningCount: 0,
|
|
282
|
+
doneCount: 0,
|
|
283
|
+
errorCount: 0,
|
|
284
|
+
},
|
|
285
|
+
controller,
|
|
286
|
+
startedAt: new Date(),
|
|
287
|
+
script: persisted.script,
|
|
288
|
+
args: persisted.args,
|
|
289
|
+
journal: persisted.journal ?? [],
|
|
290
|
+
};
|
|
291
|
+
this.runs.set(runId, managed);
|
|
238
292
|
|
|
239
|
-
|
|
240
|
-
// Full resume would require re-executing the script with cached results
|
|
293
|
+
const resumeJournal = new Map((persisted.journal ?? []).map((e) => [e.index, e] as const));
|
|
241
294
|
this.emit("resumed", { runId });
|
|
295
|
+
// Run in the background; executeRun records status/errors on the managed run.
|
|
296
|
+
void this.executeRun(managed, persisted.script, persisted.args, resumeJournal).catch(() => {});
|
|
242
297
|
return true;
|
|
243
298
|
}
|
|
244
299
|
|
package/src/workflow.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import vm from "node:vm";
|
|
2
3
|
import type { Node } from "acorn";
|
|
3
4
|
import { parse } from "acorn";
|
|
@@ -8,6 +9,7 @@ import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from ".
|
|
|
8
9
|
import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
9
10
|
import { createWorkflowLogger } from "./logger.js";
|
|
10
11
|
import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
|
|
12
|
+
import { createWorktree, removeWorktree, type Worktree } from "./worktree.js";
|
|
11
13
|
|
|
12
14
|
export interface WorkflowMetaPhase {
|
|
13
15
|
title: string;
|
|
@@ -22,6 +24,14 @@ export interface WorkflowMeta {
|
|
|
22
24
|
phases?: WorkflowMetaPhase[];
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/** One cached agent() result, keyed by its deterministic call index. */
|
|
28
|
+
export interface JournalEntry {
|
|
29
|
+
index: number;
|
|
30
|
+
/** sha256 of the call's identity (prompt + model + phase + agentType + schema). */
|
|
31
|
+
hash: string;
|
|
32
|
+
result: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
26
36
|
args?: unknown;
|
|
27
37
|
agent?: Pick<WorkflowAgent, "run">;
|
|
@@ -36,10 +46,16 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
|
36
46
|
persistLogs?: boolean;
|
|
37
47
|
/** Run ID for persistence. Auto-generated if not provided. */
|
|
38
48
|
runId?: string;
|
|
49
|
+
/** Resume: cached agent results keyed by deterministic call index. */
|
|
50
|
+
resumeJournal?: Map<number, JournalEntry>;
|
|
51
|
+
/** Resume: the run being resumed (informational; enables resume mode). */
|
|
52
|
+
resumeFromRunId?: string;
|
|
53
|
+
/** Called after each live agent completes so the caller can persist the journal. */
|
|
54
|
+
onAgentJournal?: (entry: JournalEntry) => void;
|
|
39
55
|
onLog?: (message: string) => void;
|
|
40
56
|
onPhase?: (title: string) => void;
|
|
41
57
|
onAgentStart?: (event: { label: string; phase?: string; prompt: string; model?: string }) => void;
|
|
42
|
-
onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number }) => void;
|
|
58
|
+
onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number; worktree?: string }) => void;
|
|
43
59
|
onTokenUsage?: (usage: { input: number; output: number; total: number; cost: number }) => void;
|
|
44
60
|
}
|
|
45
61
|
|
|
@@ -75,6 +91,8 @@ interface RuntimeState {
|
|
|
75
91
|
logs: string[];
|
|
76
92
|
phases: string[];
|
|
77
93
|
agentCount: number;
|
|
94
|
+
/** Monotonic, assigned at lexical agent() call time — the stable resume key. */
|
|
95
|
+
callSeq: number;
|
|
78
96
|
spent: number;
|
|
79
97
|
tokenUsage: {
|
|
80
98
|
input: number;
|
|
@@ -99,6 +117,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
99
117
|
const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
|
|
100
118
|
const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
|
|
101
119
|
const runId = options.runId ?? `run-${started.toString(36)}`;
|
|
120
|
+
const baseCwd = options.cwd ?? process.cwd();
|
|
102
121
|
|
|
103
122
|
// Initialize logger
|
|
104
123
|
const logger = createWorkflowLogger({
|
|
@@ -112,6 +131,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
112
131
|
logs: [],
|
|
113
132
|
phases: [],
|
|
114
133
|
agentCount: 0,
|
|
134
|
+
callSeq: 0,
|
|
115
135
|
spent: 0,
|
|
116
136
|
tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
|
|
117
137
|
};
|
|
@@ -170,6 +190,22 @@ export async function runWorkflow<T = unknown>(
|
|
|
170
190
|
// Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
|
|
171
191
|
const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
|
|
172
192
|
|
|
193
|
+
// Deterministic resume key: assigned at lexical call time, before the limiter,
|
|
194
|
+
// so parallel()/pipeline() fan-out is reproducible for a fixed script.
|
|
195
|
+
const callIndex = state.callSeq++;
|
|
196
|
+
const callHash = hashAgentCall(prompt, modelSpec, assignedPhase, agentOptions);
|
|
197
|
+
|
|
198
|
+
// Resume: replay a cached result for an unchanged call (matching hash), without
|
|
199
|
+
// consuming a concurrency slot, tokens, or a real subagent run.
|
|
200
|
+
const cached = options.resumeJournal?.get(callIndex);
|
|
201
|
+
if (cached && cached.hash === callHash) {
|
|
202
|
+
state.agentCount++;
|
|
203
|
+
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
204
|
+
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
|
|
205
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
|
|
206
|
+
return cached.result;
|
|
207
|
+
}
|
|
208
|
+
|
|
173
209
|
return limiter(async () => {
|
|
174
210
|
state.agentCount++;
|
|
175
211
|
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
@@ -177,6 +213,14 @@ export async function runWorkflow<T = unknown>(
|
|
|
177
213
|
|
|
178
214
|
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
|
|
179
215
|
|
|
216
|
+
// Optional per-agent worktree isolation (deterministic name -> stable resume keys).
|
|
217
|
+
let worktree: Worktree | undefined;
|
|
218
|
+
if (agentOptions.isolation === "worktree") {
|
|
219
|
+
worktree = await createWorktree(baseCwd, `${runId}-${callIndex}-${label}`);
|
|
220
|
+
if (!worktree.isolated) log(`isolation ignored for "${label}" (${worktree.reason})`);
|
|
221
|
+
}
|
|
222
|
+
const runCwd = worktree?.isolated ? worktree.cwd : undefined;
|
|
223
|
+
|
|
180
224
|
// Captured from the subagent's real session usage; falls back to an
|
|
181
225
|
// estimate when the provider reports no usage (total === 0).
|
|
182
226
|
let usage: AgentUsage | undefined;
|
|
@@ -203,6 +247,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
203
247
|
signal: options.signal,
|
|
204
248
|
instructions: buildAgentInstructions(assignedPhase, agentOptions),
|
|
205
249
|
model: modelSpec,
|
|
250
|
+
cwd: runCwd,
|
|
206
251
|
onUsage: (u: AgentUsage) => {
|
|
207
252
|
usage = u;
|
|
208
253
|
},
|
|
@@ -214,7 +259,8 @@ export async function runWorkflow<T = unknown>(
|
|
|
214
259
|
throwIfAborted();
|
|
215
260
|
|
|
216
261
|
const tokens = recordTokens(result);
|
|
217
|
-
options.
|
|
262
|
+
options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
|
|
263
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd });
|
|
218
264
|
return result;
|
|
219
265
|
} catch (error) {
|
|
220
266
|
if (options.signal?.aborted) throw error;
|
|
@@ -222,13 +268,16 @@ export async function runWorkflow<T = unknown>(
|
|
|
222
268
|
const workflowError = wrapError(error, { agentLabel: label });
|
|
223
269
|
logger.error(`agent ${label} failed: ${workflowError.message}`);
|
|
224
270
|
const tokens = recordTokens(null);
|
|
225
|
-
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens });
|
|
271
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens, worktree: runCwd });
|
|
226
272
|
|
|
227
273
|
// Return null for recoverable errors
|
|
228
274
|
if (workflowError.recoverable) {
|
|
229
275
|
return null;
|
|
230
276
|
}
|
|
231
277
|
throw workflowError;
|
|
278
|
+
} finally {
|
|
279
|
+
// Always tear down the worktree, even on timeout/abort.
|
|
280
|
+
if (worktree?.isolated) await removeWorktree(worktree);
|
|
232
281
|
}
|
|
233
282
|
});
|
|
234
283
|
};
|
|
@@ -481,6 +530,23 @@ function defaultAgentLabel(phase: string | undefined, index: number): string {
|
|
|
481
530
|
return phase ? `${phase} agent ${index}` : `agent ${index}`;
|
|
482
531
|
}
|
|
483
532
|
|
|
533
|
+
/** Stable identity hash for an agent() call — a cache miss on resume when anything changes. */
|
|
534
|
+
function hashAgentCall(
|
|
535
|
+
prompt: string,
|
|
536
|
+
model: string | undefined,
|
|
537
|
+
phase: string | undefined,
|
|
538
|
+
options: AgentOptions,
|
|
539
|
+
): string {
|
|
540
|
+
const identity = JSON.stringify({
|
|
541
|
+
prompt,
|
|
542
|
+
model: model ?? null,
|
|
543
|
+
phase: phase ?? null,
|
|
544
|
+
agentType: options.agentType ?? null,
|
|
545
|
+
schema: options.schema ?? null,
|
|
546
|
+
});
|
|
547
|
+
return createHash("sha256").update(identity).digest("hex");
|
|
548
|
+
}
|
|
549
|
+
|
|
484
550
|
function buildAgentInstructions(phase: string | undefined, options: AgentOptions): string | undefined {
|
|
485
551
|
const lines = [];
|
|
486
552
|
if (phase) lines.push(`Workflow phase: ${phase}`);
|
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent git worktree isolation. When an agent requests `isolation: "worktree"`,
|
|
3
|
+
* it runs in a throwaway worktree on its own branch so parallel agents can edit the
|
|
4
|
+
* same files without conflict. Results are NOT auto-merged — the path is surfaced for
|
|
5
|
+
* the caller to inspect. Falls back to a logged no-op when isolation isn't possible.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
|
|
12
|
+
const exec = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
export interface Worktree {
|
|
15
|
+
/** True when a real worktree was created; false means "ran in the shared tree". */
|
|
16
|
+
isolated: boolean;
|
|
17
|
+
/** cwd the agent should run in (worktree path when isolated, else the base cwd). */
|
|
18
|
+
cwd: string;
|
|
19
|
+
branch?: string;
|
|
20
|
+
/** Repo root the worktree was added to (for teardown). */
|
|
21
|
+
repoRoot?: string;
|
|
22
|
+
/** Why isolation was skipped, when isolated === false. */
|
|
23
|
+
reason?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function slug(name: string): string {
|
|
27
|
+
return (
|
|
28
|
+
name
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
31
|
+
.replace(/^-+|-+$/g, "")
|
|
32
|
+
.slice(0, 32) || "agent"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create an isolated worktree under `<repoRoot>/.pi/worktrees/<name>` on branch
|
|
38
|
+
* `pi/wf/<name>`. The `name` must be deterministic (derived from runId + call index,
|
|
39
|
+
* never wall-clock) so resume keys stay stable. Returns a no-op Worktree on any failure.
|
|
40
|
+
*/
|
|
41
|
+
export async function createWorktree(baseCwd: string, name: string): Promise<Worktree> {
|
|
42
|
+
const id = slug(name);
|
|
43
|
+
let repoRoot: string;
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await exec("git", ["-C", baseCwd, "rev-parse", "--show-toplevel"]);
|
|
46
|
+
repoRoot = stdout.trim();
|
|
47
|
+
} catch {
|
|
48
|
+
return { isolated: false, cwd: baseCwd, reason: "not a git repository" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const path = join(repoRoot, ".pi", "worktrees", id);
|
|
52
|
+
const branch = `pi/wf/${id}`;
|
|
53
|
+
try {
|
|
54
|
+
await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
|
|
55
|
+
return { isolated: true, cwd: path, branch, repoRoot };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return { isolated: false, cwd: baseCwd, reason: error instanceof Error ? error.message : String(error) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Remove a worktree and its branch. Best-effort; safe to call on a no-op Worktree. */
|
|
62
|
+
export async function removeWorktree(wt: Worktree): Promise<void> {
|
|
63
|
+
if (!wt.isolated || !wt.repoRoot) return;
|
|
64
|
+
try {
|
|
65
|
+
await exec("git", ["-C", wt.repoRoot, "worktree", "remove", "--force", wt.cwd]);
|
|
66
|
+
} catch {
|
|
67
|
+
// already gone / locked — fall through
|
|
68
|
+
}
|
|
69
|
+
if (wt.branch) {
|
|
70
|
+
try {
|
|
71
|
+
await exec("git", ["-C", wt.repoRoot, "branch", "-D", wt.branch]);
|
|
72
|
+
} catch {
|
|
73
|
+
// branch already deleted
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|