pi-agent-fleet 0.3.0 → 0.4.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 +84 -23
- package/assets/canvas-node-peek.png +0 -0
- package/assets/canvas.png +0 -0
- package/package.json +11 -3
- package/src/canvas-client.tsx +669 -0
- package/src/canvas.ts +570 -319
- package/src/controller.ts +8 -2
- package/src/dag.ts +58 -0
- package/src/scheduler.ts +113 -0
- package/src/worktree.ts +75 -0
package/src/controller.ts
CHANGED
|
@@ -166,12 +166,15 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
166
166
|
const prompt = await readFile(join(fleet.fleetRoot, "workers", nodeId, "prompt.md"), "utf-8");
|
|
167
167
|
const sessionDir = join(fleet.fleetRoot, "workers", nodeId);
|
|
168
168
|
const effort = worker.effort ?? fleet.spec.config.effort ?? "medium";
|
|
169
|
+
const worktreeCwd = worker?.worktree || worker?.id === "fleet-integrator"
|
|
170
|
+
? join(fleet.fleetRoot, "worktrees", nodeId)
|
|
171
|
+
: ctx.cwd;
|
|
169
172
|
try {
|
|
170
173
|
return await runWorker({
|
|
171
174
|
nodeId,
|
|
172
175
|
worker: workerWithResolvedModel(worker, resolvedModel),
|
|
173
176
|
prompt,
|
|
174
|
-
repoCwd:
|
|
177
|
+
repoCwd: worktreeCwd,
|
|
175
178
|
sessionDir,
|
|
176
179
|
thinkingLevel: effort,
|
|
177
180
|
sessionFactory: resolvedModel ? sessionFactoryForModel(resolvedModel) : undefined,
|
|
@@ -200,9 +203,12 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
200
203
|
const state = await runFleet({
|
|
201
204
|
spec: fleet.spec,
|
|
202
205
|
fleetRoot: fleet.fleetRoot,
|
|
206
|
+
baseRepo: ctx.cwd,
|
|
203
207
|
repoCwd: (nodeId) => {
|
|
204
208
|
const worker = fleet.spec.workers.find((w) => w.id === nodeId);
|
|
205
|
-
return worker?.worktree
|
|
209
|
+
return worker?.worktree || worker?.id === "fleet-integrator"
|
|
210
|
+
? join(fleet.fleetRoot, "worktrees", nodeId)
|
|
211
|
+
: ctx.cwd;
|
|
206
212
|
},
|
|
207
213
|
spawn,
|
|
208
214
|
killSwitch: fleet.killSwitch,
|
package/src/dag.ts
CHANGED
|
@@ -49,6 +49,47 @@ export function getDependents(spec: FleetSpec, nodeId: string): string[] {
|
|
|
49
49
|
return spec.workers.filter((w) => w.depends_on.includes(nodeId)).map((w) => w.id);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function findWorktreeOwnershipConflicts(workers: WorkerSpec[]): string[] {
|
|
53
|
+
const worktrees = workers.filter((w) => w.worktree);
|
|
54
|
+
const claims = new Map<string, string[]>();
|
|
55
|
+
for (const w of worktrees) {
|
|
56
|
+
for (const o of w.outputs) {
|
|
57
|
+
if (!o.path.startsWith("output/")) {
|
|
58
|
+
const list = claims.get(o.path) ?? [];
|
|
59
|
+
list.push(w.id);
|
|
60
|
+
claims.set(o.path, list);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const errors: string[] = [];
|
|
65
|
+
const byId = new Map(workers.map((w) => [w.id, w]));
|
|
66
|
+
for (const [path, ids] of claims) {
|
|
67
|
+
if (ids.length < 2) continue;
|
|
68
|
+
for (let i = 0; i < ids.length; i++) {
|
|
69
|
+
for (let j = i + 1; j < ids.length; j++) {
|
|
70
|
+
const a = ids[i], b = ids[j];
|
|
71
|
+
const aBeforeB = byId.get(a)!.depends_on.includes(b);
|
|
72
|
+
const bBeforeA = byId.get(b)!.depends_on.includes(a);
|
|
73
|
+
if (!aBeforeB && !bBeforeA) {
|
|
74
|
+
errors.push(`worktree ownership conflict: "${path}" claimed by "${a}" and "${b}" without ordered handoff`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return errors;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasIntegratorPath(workers: WorkerSpec[]): boolean {
|
|
83
|
+
const worktreeIds = new Set(workers.filter((w) => w.worktree).map((w) => w.id));
|
|
84
|
+
if (worktreeIds.size < 2) return true;
|
|
85
|
+
return workers.some((w) => {
|
|
86
|
+
if (w.worktree) return false;
|
|
87
|
+
const deps = new Set(w.depends_on);
|
|
88
|
+
for (const id of worktreeIds) if (!deps.has(id)) return false;
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
52
93
|
export function validateFleetSpec(
|
|
53
94
|
raw: unknown,
|
|
54
95
|
): { ok: true; spec: FleetSpec; layers: string[][] } | { ok: false; errors: string[] } {
|
|
@@ -115,6 +156,23 @@ export function validateFleetSpec(
|
|
|
115
156
|
}
|
|
116
157
|
}
|
|
117
158
|
|
|
159
|
+
const worktreeIds = new Set(workers.filter((w) => w.worktree).map((w) => w.id));
|
|
160
|
+
if (worktreeIds.size >= 2) {
|
|
161
|
+
if (!workers.some((w) => w.id === "fleet-integrator")) {
|
|
162
|
+
workers.push({
|
|
163
|
+
id: "fleet-integrator",
|
|
164
|
+
type: "code-run",
|
|
165
|
+
task: `Merge worktree branches in order: ${[...worktreeIds].join(", ")}. Verify the combined repo is consistent and commit if needed.`,
|
|
166
|
+
depends_on: [...worktreeIds],
|
|
167
|
+
outputs: [],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (!hasIntegratorPath(workers)) {
|
|
171
|
+
errors.push("multiple worktree workers require an integrator that depends on all worktree workers");
|
|
172
|
+
}
|
|
173
|
+
errors.push(...findWorktreeOwnershipConflicts(workers));
|
|
174
|
+
}
|
|
175
|
+
|
|
118
176
|
let loopConfig: LoopConfig | undefined;
|
|
119
177
|
if (cfg.loop !== undefined && cfg.loop !== null) {
|
|
120
178
|
if (typeof cfg.loop !== "object" || Array.isArray(cfg.loop)) {
|
package/src/scheduler.ts
CHANGED
|
@@ -4,12 +4,14 @@ import { verifyOutputs } from "./contracts.js";
|
|
|
4
4
|
import { archiveIteration, initFleetState, patchNode, resetForIteration, snapshotIteration, writeState } from "./state.js";
|
|
5
5
|
import { TERMINAL_NODE_STATUSES } from "./types.js";
|
|
6
6
|
import type { FleetSpec, FleetState, IterationSnapshot, NodeState, Verdict, WorkerSpec } from "./types.js";
|
|
7
|
+
import { commitWorktree, createWorktree, prepareIntegratorWorktree } from "./worktree.js";
|
|
7
8
|
|
|
8
9
|
export type SpawnFn = (nodeId: string) => Promise<{ ok: boolean; turns: number; tokens: number; cost?: number; error?: string }>;
|
|
9
10
|
|
|
10
11
|
export interface RunFleetOpts {
|
|
11
12
|
spec: FleetSpec;
|
|
12
13
|
fleetRoot: string;
|
|
14
|
+
baseRepo?: string;
|
|
13
15
|
repoCwd: string | ((nodeId: string) => string);
|
|
14
16
|
spawn: SpawnFn;
|
|
15
17
|
onNodeChange?: (nodeId: string, s: NodeState) => void;
|
|
@@ -72,6 +74,41 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
72
74
|
const repoCwdFor = (nodeId: string): string =>
|
|
73
75
|
typeof opts.repoCwd === "function" ? opts.repoCwd(nodeId) : opts.repoCwd;
|
|
74
76
|
|
|
77
|
+
const baseRepo = (): string | undefined =>
|
|
78
|
+
opts.baseRepo ?? (typeof opts.repoCwd === "string" ? opts.repoCwd : undefined);
|
|
79
|
+
|
|
80
|
+
function orderedWorktreeBranches(spec: FleetSpec): string[] {
|
|
81
|
+
const ids = spec.workers.filter((w) => w.worktree).map((w) => w.id);
|
|
82
|
+
const set = new Set(ids);
|
|
83
|
+
const indeg = new Map(ids.map((id) => [id, 0]));
|
|
84
|
+
const rev = new Map(ids.map((id) => [id, [] as string[]]));
|
|
85
|
+
for (const w of spec.workers) {
|
|
86
|
+
if (!set.has(w.id)) continue;
|
|
87
|
+
for (const d of w.depends_on) {
|
|
88
|
+
if (set.has(d)) {
|
|
89
|
+
indeg.set(w.id, (indeg.get(w.id) ?? 0) + 1);
|
|
90
|
+
rev.get(d)?.push(w.id);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const sorted: string[] = [];
|
|
95
|
+
let current = ids.filter((id) => indeg.get(id) === 0);
|
|
96
|
+
while (current.length > 0) {
|
|
97
|
+
sorted.push(...current);
|
|
98
|
+
const next: string[] = [];
|
|
99
|
+
for (const id of current) {
|
|
100
|
+
for (const m of rev.get(id) ?? []) {
|
|
101
|
+
const v = (indeg.get(m) ?? 0) - 1;
|
|
102
|
+
indeg.set(m, v);
|
|
103
|
+
if (v === 0) next.push(m);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
current = next;
|
|
107
|
+
}
|
|
108
|
+
if (sorted.length !== ids.length) sorted.push(...ids.filter((id) => !sorted.includes(id)));
|
|
109
|
+
return sorted.map((id) => `fleet/${spec.fleet_name}/${id}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
75
112
|
const runPass = async (): Promise<void> => {
|
|
76
113
|
while (true) {
|
|
77
114
|
// auto-initialize workers inserted into the spec after the run started
|
|
@@ -122,6 +159,61 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
122
159
|
const depsDone = w.depends_on.every((d) => state.nodes[d]?.status === "completed");
|
|
123
160
|
if (!depsDone) continue;
|
|
124
161
|
slots--;
|
|
162
|
+
|
|
163
|
+
if (w.worktree) {
|
|
164
|
+
const repo = baseRepo();
|
|
165
|
+
if (!repo) {
|
|
166
|
+
await patch(w.id, {
|
|
167
|
+
status: "failed",
|
|
168
|
+
ended_at: new Date().toISOString(),
|
|
169
|
+
status_note: "worktree worker requires a baseRepo",
|
|
170
|
+
});
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
await createWorktree({
|
|
175
|
+
baseRepo: repo,
|
|
176
|
+
fleetName: spec.fleet_name,
|
|
177
|
+
nodeId: w.id,
|
|
178
|
+
fleetRoot,
|
|
179
|
+
});
|
|
180
|
+
} catch (e) {
|
|
181
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
182
|
+
await patch(w.id, {
|
|
183
|
+
status: "failed",
|
|
184
|
+
ended_at: new Date().toISOString(),
|
|
185
|
+
status_note: `worktree creation failed: ${msg}`,
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (w.id === "fleet-integrator") {
|
|
192
|
+
const repo = baseRepo();
|
|
193
|
+
if (!repo) {
|
|
194
|
+
await patch(w.id, {
|
|
195
|
+
status: "failed",
|
|
196
|
+
ended_at: new Date().toISOString(),
|
|
197
|
+
status_note: "integrator requires a baseRepo",
|
|
198
|
+
});
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const prep = await prepareIntegratorWorktree({
|
|
202
|
+
baseRepo: repo,
|
|
203
|
+
fleetName: spec.fleet_name,
|
|
204
|
+
fleetRoot,
|
|
205
|
+
branches: orderedWorktreeBranches(spec),
|
|
206
|
+
});
|
|
207
|
+
if (!prep.ok) {
|
|
208
|
+
await patch(w.id, {
|
|
209
|
+
status: "failed",
|
|
210
|
+
ended_at: new Date().toISOString(),
|
|
211
|
+
status_note: prep.conflict,
|
|
212
|
+
});
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
125
217
|
await patch(w.id, { status: "running", started_at: new Date().toISOString() });
|
|
126
218
|
const p = opts.spawn(w.id).then(async (res) => {
|
|
127
219
|
if (opts.killSwitch?.killed) return;
|
|
@@ -133,6 +225,27 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
133
225
|
await patch(w.id, { status: "failed", ended_at: new Date().toISOString(), turns: res.turns, tokens: res.tokens, cost_usd_estimate: res.cost ?? 0 });
|
|
134
226
|
return;
|
|
135
227
|
}
|
|
228
|
+
if (w.worktree) {
|
|
229
|
+
try {
|
|
230
|
+
await commitWorktree({
|
|
231
|
+
worktreePath: repoCwdFor(w.id),
|
|
232
|
+
nodeId: w.id,
|
|
233
|
+
fleetName: spec.fleet_name,
|
|
234
|
+
iteration: state.iteration,
|
|
235
|
+
});
|
|
236
|
+
} catch (e) {
|
|
237
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
238
|
+
await patch(w.id, {
|
|
239
|
+
status: "failed",
|
|
240
|
+
ended_at: new Date().toISOString(),
|
|
241
|
+
turns: res.turns,
|
|
242
|
+
tokens: res.tokens,
|
|
243
|
+
cost_usd_estimate: res.cost ?? 0,
|
|
244
|
+
status_note: `commit failed: ${msg}`,
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
136
249
|
const contract = await verifyOutputs({
|
|
137
250
|
workerDir: `${fleetRoot}/workers/${w.id}`,
|
|
138
251
|
repoCwd: repoCwdFor(w.id),
|
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
const execFileP = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
export interface CreateWorktreeOpts {
|
|
9
|
+
baseRepo: string;
|
|
10
|
+
fleetName: string;
|
|
11
|
+
nodeId: string;
|
|
12
|
+
fleetRoot: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function createWorktree(opts: CreateWorktreeOpts): Promise<string> {
|
|
16
|
+
const path = join(opts.fleetRoot, "worktrees", opts.nodeId);
|
|
17
|
+
const branch = `fleet/${opts.fleetName}/${opts.nodeId}`;
|
|
18
|
+
await mkdir(path, { recursive: true });
|
|
19
|
+
await removeWorktree(path, opts.baseRepo);
|
|
20
|
+
await execFileP("git", ["worktree", "add", "-b", branch, path], { cwd: opts.baseRepo });
|
|
21
|
+
return path;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CommitWorktreeOpts {
|
|
25
|
+
worktreePath: string;
|
|
26
|
+
nodeId: string;
|
|
27
|
+
fleetName: string;
|
|
28
|
+
iteration: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function commitWorktree(opts: CommitWorktreeOpts): Promise<void> {
|
|
32
|
+
await execFileP("git", ["add", "-A"], { cwd: opts.worktreePath });
|
|
33
|
+
try {
|
|
34
|
+
await execFileP(
|
|
35
|
+
"git",
|
|
36
|
+
["commit", "-m", `fleet: ${opts.fleetName} ${opts.nodeId} iteration ${opts.iteration}`],
|
|
37
|
+
{ cwd: opts.worktreePath },
|
|
38
|
+
);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
41
|
+
if (msg.includes("nothing to commit")) return;
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PrepareIntegratorOpts {
|
|
47
|
+
baseRepo: string;
|
|
48
|
+
fleetName: string;
|
|
49
|
+
fleetRoot: string;
|
|
50
|
+
branches: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function prepareIntegratorWorktree(
|
|
54
|
+
opts: PrepareIntegratorOpts,
|
|
55
|
+
): Promise<{ path: string; ok: boolean; conflict?: string }> {
|
|
56
|
+
const path = join(opts.fleetRoot, "worktrees", "fleet-integrator");
|
|
57
|
+
await removeWorktree(path, opts.baseRepo);
|
|
58
|
+
await mkdir(path, { recursive: true });
|
|
59
|
+
await execFileP("git", ["worktree", "add", "-b", `fleet/${opts.fleetName}/fleet-integrator`, path], {
|
|
60
|
+
cwd: opts.baseRepo,
|
|
61
|
+
});
|
|
62
|
+
for (const branch of opts.branches) {
|
|
63
|
+
try {
|
|
64
|
+
await execFileP("git", ["merge", "--no-ff", "--no-edit", branch], { cwd: path });
|
|
65
|
+
} catch (e) {
|
|
66
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
67
|
+
return { path, ok: false, conflict: `merge ${branch} failed: ${msg}` };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { path, ok: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function removeWorktree(path: string, baseRepo: string): Promise<void> {
|
|
74
|
+
await execFileP("git", ["worktree", "remove", "--force", path], { cwd: baseRepo }).catch(() => {});
|
|
75
|
+
}
|