pi-agent-fleet 0.2.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/LICENSE +21 -0
- package/README.md +122 -0
- package/examples/two-worker-fleet.json +21 -0
- package/package.json +52 -0
- package/src/contracts.ts +78 -0
- package/src/dag.ts +190 -0
- package/src/index.ts +600 -0
- package/src/placeholder.ts +1 -0
- package/src/prompts.ts +116 -0
- package/src/report.ts +81 -0
- package/src/runner.ts +95 -0
- package/src/scheduler.ts +226 -0
- package/src/state.ts +156 -0
- package/src/types.ts +109 -0
- package/src/ui.ts +30 -0
- package/src/viz.ts +36 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { createAgentSession, SessionManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { validateFleetSpec } from "./dag.js";
|
|
7
|
+
import { buildWorkerPrompt } from "./prompts.js";
|
|
8
|
+
import { writeReport } from "./report.js";
|
|
9
|
+
import { runWorker, type AgentSessionLike, type SessionFactory } from "./runner.js";
|
|
10
|
+
import { runFleet } from "./scheduler.js";
|
|
11
|
+
import { initFleetState, patchNode, readState, resetForRelaunch, writeState } from "./state.js";
|
|
12
|
+
import type { FleetSpec, FleetState, WorkerSpec } from "./types.js";
|
|
13
|
+
import { buildWidgetLines } from "./ui.js";
|
|
14
|
+
import { dagNeedsFileFallback, renderDag } from "./viz.js";
|
|
15
|
+
|
|
16
|
+
export interface ActiveFleet {
|
|
17
|
+
spec: FleetSpec;
|
|
18
|
+
fleetRoot: string;
|
|
19
|
+
state: FleetState;
|
|
20
|
+
killSwitch: { killed: boolean };
|
|
21
|
+
pauseSwitch: { paused: boolean };
|
|
22
|
+
running: boolean;
|
|
23
|
+
costWarned?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let active: ActiveFleet | undefined;
|
|
27
|
+
|
|
28
|
+
function fleetRootFor(cwd: string, name: string): string {
|
|
29
|
+
const ts = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
30
|
+
return join(cwd, ".fleet", `${name}-${ts}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function textResult(text: string, details: Record<string, unknown> = {}) {
|
|
34
|
+
return { content: [{ type: "text" as const, text }], details };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function isInsideGitRepo(cwd: string): Promise<boolean> {
|
|
38
|
+
let dir = cwd;
|
|
39
|
+
while (true) {
|
|
40
|
+
try {
|
|
41
|
+
const s = await stat(join(dir, ".git"));
|
|
42
|
+
if (s.isFile() || s.isDirectory()) return true;
|
|
43
|
+
} catch {
|
|
44
|
+
// any stat failure (ENOENT, EACCES, etc.) is treated as not-found
|
|
45
|
+
}
|
|
46
|
+
const parent = dirname(dir);
|
|
47
|
+
if (parent === dir) return false;
|
|
48
|
+
dir = parent;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function ensureFleetGitignore(cwd: string): Promise<void> {
|
|
53
|
+
const dir = join(cwd, ".fleet");
|
|
54
|
+
const gitignore = join(dir, ".gitignore");
|
|
55
|
+
await mkdir(dir, { recursive: true });
|
|
56
|
+
let current = "";
|
|
57
|
+
try {
|
|
58
|
+
current = await readFile(gitignore, "utf-8");
|
|
59
|
+
} catch {
|
|
60
|
+
// created below
|
|
61
|
+
}
|
|
62
|
+
if (!current.split(/\r?\n/).includes("*")) {
|
|
63
|
+
const prefix = current.length > 0 && !current.endsWith("\n") ? "\n" : "";
|
|
64
|
+
await writeFile(gitignore, `${current}${prefix}*\n`, "utf-8");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function writePlanFiles(fleetRoot: string, spec: FleetSpec, state: FleetState): Promise<void> {
|
|
69
|
+
await mkdir(fleetRoot, { recursive: true });
|
|
70
|
+
await Promise.all(spec.workers.map((w) => mkdir(join(fleetRoot, "workers", w.id, "output"), { recursive: true })));
|
|
71
|
+
await writeFile(join(fleetRoot, "fleet.json"), `${JSON.stringify(spec, null, 2)}\n`, "utf-8");
|
|
72
|
+
await writeState(fleetRoot, state);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function writeWorkerPrompts(fleet: ActiveFleet): Promise<void> {
|
|
76
|
+
await Promise.all(fleet.spec.workers.map(async (w) => {
|
|
77
|
+
const prompt = buildWorkerPrompt({ spec: fleet.spec, state: fleet.state, workerId: w.id, fleetRoot: fleet.fleetRoot });
|
|
78
|
+
await writeFile(join(fleet.fleetRoot, "workers", w.id, "prompt.md"), prompt, "utf-8");
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function aliasesFor(model: Model<Api>): string[] {
|
|
83
|
+
const extra = model as Model<Api> & { alias?: unknown; aliases?: unknown };
|
|
84
|
+
return [
|
|
85
|
+
model.id,
|
|
86
|
+
model.name,
|
|
87
|
+
...(typeof extra.alias === "string" ? [extra.alias] : []),
|
|
88
|
+
...(Array.isArray(extra.aliases) ? extra.aliases.filter((a): a is string => typeof a === "string") : []),
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveModelReference(
|
|
93
|
+
registry: ExtensionContext["modelRegistry"],
|
|
94
|
+
ref: string,
|
|
95
|
+
): { ok: true; model: Model<Api> } | { ok: false; error: string } {
|
|
96
|
+
const models = registry.getAvailable();
|
|
97
|
+
const pool = models.length > 0 ? models : registry.getAll();
|
|
98
|
+
const needle = ref.toLowerCase();
|
|
99
|
+
const canonical = (m: Model<Api>) => `${m.provider}/${m.id}`.toLowerCase();
|
|
100
|
+
const byAlias = (m: Model<Api>, pred: (v: string) => boolean) => aliasesFor(m).some((a) => pred(a.toLowerCase()));
|
|
101
|
+
|
|
102
|
+
const tiers: Model<Api>[][] = [];
|
|
103
|
+
if (needle.includes("/")) {
|
|
104
|
+
tiers.push(pool.filter((m) => canonical(m) === needle));
|
|
105
|
+
const [provider, ...rest] = needle.split("/");
|
|
106
|
+
const alias = rest.join("/");
|
|
107
|
+
tiers.push(pool.filter((m) => m.provider.toLowerCase() === provider && byAlias(m, (a) => a === alias)));
|
|
108
|
+
}
|
|
109
|
+
tiers.push(pool.filter((m) => m.id.toLowerCase() === needle));
|
|
110
|
+
tiers.push(pool.filter((m) => byAlias(m, (a) => a === needle)));
|
|
111
|
+
tiers.push(pool.filter((m) => byAlias(m, (a) => a.startsWith(needle))));
|
|
112
|
+
tiers.push(pool.filter((m) => byAlias(m, (a) => a.includes(needle))));
|
|
113
|
+
|
|
114
|
+
for (const tier of tiers) {
|
|
115
|
+
const unique = [...new Map(tier.map((m) => [`${m.provider}/${m.id}`, m])).values()];
|
|
116
|
+
if (unique.length === 1) return { ok: true, model: unique[0] };
|
|
117
|
+
if (unique.length > 1) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: `model "${ref}" is ambiguous: ${unique.map((m) => `${m.provider}/${m.id}`).join(", ")}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { ok: false, error: `model "${ref}" not found` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sessionFactoryForModel(model: Model<Api>): SessionFactory {
|
|
128
|
+
return async (opts) => {
|
|
129
|
+
const { session } = await createAgentSession({
|
|
130
|
+
cwd: opts.cwd,
|
|
131
|
+
tools: opts.tools,
|
|
132
|
+
sessionManager: SessionManager.create(opts.cwd, opts.sessionDir),
|
|
133
|
+
model,
|
|
134
|
+
});
|
|
135
|
+
return session as unknown as AgentSessionLike;
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function workerWithResolvedModel(worker: WorkerSpec, model: Model<Api> | undefined): WorkerSpec {
|
|
140
|
+
return model ? { ...worker, model: `${model.provider}/${model.id}` } : worker;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function updateWidget(ctx: ExtensionContext, fleet: ActiveFleet): void {
|
|
144
|
+
if (ctx.hasUI) ctx.ui.setWidget("fleet", buildWidgetLines(fleet.spec, fleet.state));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function currentState(fleet: ActiveFleet): Promise<FleetState> {
|
|
148
|
+
if (fleet.running) return fleet.state;
|
|
149
|
+
try {
|
|
150
|
+
fleet.state = await readState(fleet.fleetRoot);
|
|
151
|
+
} catch {
|
|
152
|
+
// keep in-memory state
|
|
153
|
+
}
|
|
154
|
+
return fleet.state;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function statusText(fleet: ActiveFleet): Promise<string> {
|
|
158
|
+
const state = await currentState(fleet);
|
|
159
|
+
return renderDag(fleet.spec, state);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function dagPreview(spec: FleetSpec, state: FleetState | undefined, fleetRoot: string): Promise<string> {
|
|
163
|
+
const out = renderDag(spec, state);
|
|
164
|
+
const width = process.stdout.columns ?? 120;
|
|
165
|
+
if (dagNeedsFileFallback(spec, width)) {
|
|
166
|
+
const p = join(fleetRoot, "dag.txt");
|
|
167
|
+
await writeFile(p, out, "utf-8");
|
|
168
|
+
return `${out}\n\nDAG preview saved: ${p}`;
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resume = false, continuePass = false): Promise<void> {
|
|
174
|
+
fleet.running = true;
|
|
175
|
+
fleet.costWarned = false;
|
|
176
|
+
updateWidget(ctx, fleet);
|
|
177
|
+
|
|
178
|
+
const checkCostWarning = () => {
|
|
179
|
+
const warn = fleet.spec.config.warn_cost_usd;
|
|
180
|
+
if (!warn || fleet.costWarned) return;
|
|
181
|
+
const cost = fleet.state.cost_usd_estimate;
|
|
182
|
+
if (cost >= warn) {
|
|
183
|
+
fleet.costWarned = true;
|
|
184
|
+
if (ctx.hasUI) ctx.ui.notify(`fleet cost warning: $${cost.toFixed(4)} >= $${warn}`, "warning");
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const spawn = async (nodeId: string) => {
|
|
189
|
+
try {
|
|
190
|
+
const worker = fleet.spec.workers.find((w) => w.id === nodeId);
|
|
191
|
+
if (!worker) return { ok: false, turns: 0, tokens: 0, error: `unknown worker "${nodeId}"` };
|
|
192
|
+
|
|
193
|
+
let resolvedModel: Model<Api> | undefined;
|
|
194
|
+
let modelNote: string | undefined;
|
|
195
|
+
if (worker.model) {
|
|
196
|
+
const resolved = resolveModelReference(ctx.modelRegistry, worker.model);
|
|
197
|
+
if (!resolved.ok) return { ok: false, turns: 0, tokens: 0, error: resolved.error };
|
|
198
|
+
resolvedModel = resolved.model;
|
|
199
|
+
} else if (fleet.spec.config.model) {
|
|
200
|
+
const resolved = resolveModelReference(ctx.modelRegistry, fleet.spec.config.model);
|
|
201
|
+
if (resolved.ok) resolvedModel = resolved.model;
|
|
202
|
+
else modelNote = `config.model "${fleet.spec.config.model}" not found, using session default`;
|
|
203
|
+
}
|
|
204
|
+
if (modelNote) {
|
|
205
|
+
fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { status_note: modelNote });
|
|
206
|
+
updateWidget(ctx, fleet);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const prompt = await readFile(join(fleet.fleetRoot, "workers", nodeId, "prompt.md"), "utf-8");
|
|
210
|
+
const sessionDir = join(fleet.fleetRoot, "workers", nodeId);
|
|
211
|
+
return await runWorker({
|
|
212
|
+
nodeId,
|
|
213
|
+
worker: workerWithResolvedModel(worker, resolvedModel),
|
|
214
|
+
prompt,
|
|
215
|
+
repoCwd: ctx.cwd,
|
|
216
|
+
sessionDir,
|
|
217
|
+
sessionFactory: resolvedModel ? sessionFactoryForModel(resolvedModel) : undefined,
|
|
218
|
+
onEvent: (e) => {
|
|
219
|
+
if (e.type === "turn") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { turns: e.turns });
|
|
220
|
+
if (e.type === "tokens") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { tokens: e.tokens });
|
|
221
|
+
if (e.type === "cost") {
|
|
222
|
+
fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { cost_usd_estimate: e.cost });
|
|
223
|
+
checkCostWarning();
|
|
224
|
+
}
|
|
225
|
+
if (e.type === "error") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { status_note: e.message });
|
|
226
|
+
updateWidget(ctx, fleet);
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
} catch (err) {
|
|
230
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
231
|
+
return { ok: false, turns: 0, tokens: 0, error };
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
let resumeFrom: FleetState | undefined;
|
|
236
|
+
if (continuePass) {
|
|
237
|
+
resumeFrom = fleet.state;
|
|
238
|
+
} else if (resume) {
|
|
239
|
+
resumeFrom = await readState(fleet.fleetRoot);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const state = await runFleet({
|
|
244
|
+
spec: fleet.spec,
|
|
245
|
+
fleetRoot: fleet.fleetRoot,
|
|
246
|
+
repoCwd: (nodeId) => {
|
|
247
|
+
const worker = fleet.spec.workers.find((w) => w.id === nodeId);
|
|
248
|
+
return worker?.worktree ? join(fleet.fleetRoot, "worktrees", nodeId) : ctx.cwd;
|
|
249
|
+
},
|
|
250
|
+
spawn,
|
|
251
|
+
killSwitch: fleet.killSwitch,
|
|
252
|
+
pauseSwitch: fleet.pauseSwitch,
|
|
253
|
+
onNodeChange: (nodeId, nodeState) => {
|
|
254
|
+
fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, nodeState);
|
|
255
|
+
checkCostWarning();
|
|
256
|
+
updateWidget(ctx, fleet);
|
|
257
|
+
},
|
|
258
|
+
prepareIteration: async (_n, state) => {
|
|
259
|
+
fleet.state = state;
|
|
260
|
+
await writeWorkerPrompts(fleet);
|
|
261
|
+
},
|
|
262
|
+
resumeFrom,
|
|
263
|
+
continuePass,
|
|
264
|
+
});
|
|
265
|
+
fleet.state = state;
|
|
266
|
+
fleet.running = false;
|
|
267
|
+
if (ctx.hasUI) ctx.ui.setWidget("fleet", []);
|
|
268
|
+
const report = await writeReport({ spec: fleet.spec, state, fleetRoot: fleet.fleetRoot, repoCwd: ctx.cwd });
|
|
269
|
+
const last = state.iterations[state.iterations.length - 1];
|
|
270
|
+
if (state.status === "paused" && last?.verdict === "escalate") {
|
|
271
|
+
if (ctx.hasUI) ctx.ui.notify(`fleet paused: reviewer escalated; report: ${join(fleet.fleetRoot, "report.md")}`, "warning");
|
|
272
|
+
} else if (ctx.hasUI) {
|
|
273
|
+
ctx.ui.notify(`fleet ${state.status}; report: ${join(fleet.fleetRoot, "report.md")}`, state.status === "completed" ? "info" : "warning");
|
|
274
|
+
}
|
|
275
|
+
} catch (err: unknown) {
|
|
276
|
+
fleet.running = false;
|
|
277
|
+
if (ctx.hasUI) ctx.ui.setWidget("fleet", []);
|
|
278
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
279
|
+
if (ctx.hasUI) ctx.ui.notify(`fleet failed: ${error}`, "error");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function killFleet(target: string): Promise<string> {
|
|
284
|
+
if (!active) return "no fleet planned yet";
|
|
285
|
+
if (target !== "all") return "single-node kill not supported in v1 — use target \"all\"";
|
|
286
|
+
active.killSwitch.killed = true;
|
|
287
|
+
return "fleet kill requested";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export default function (pi: ExtensionAPI) {
|
|
291
|
+
pi.on("session_start", (_event, ctx) => {
|
|
292
|
+
active = undefined;
|
|
293
|
+
ctx.ui.setStatus("fleet", "");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const OutputSchema = Type.Object({
|
|
297
|
+
path: Type.String({ description: 'output/... paths resolve under the worker dir; anything else is repo-relative (code edits)' }),
|
|
298
|
+
kind: Type.Union([
|
|
299
|
+
Type.Literal("markdown"), Type.Literal("file-exists"),
|
|
300
|
+
Type.Literal("verdict"), Type.Literal("json"), Type.Literal("yaml"),
|
|
301
|
+
]),
|
|
302
|
+
required: Type.Optional(Type.Boolean()),
|
|
303
|
+
});
|
|
304
|
+
const WorkerSchema = Type.Object({
|
|
305
|
+
id: Type.String({ description: "kebab-case, e.g. counter-a" }),
|
|
306
|
+
type: Type.Union([
|
|
307
|
+
Type.Literal("research"), Type.Literal("code-run"),
|
|
308
|
+
Type.Literal("reviewer"), Type.Literal("write"), Type.Literal("read-only"),
|
|
309
|
+
]),
|
|
310
|
+
task: Type.String({ description: "Full task instructions for the worker" }),
|
|
311
|
+
model: Type.Optional(Type.String({ description: "Per-worker model override, e.g. gpt-5.4-mini" })),
|
|
312
|
+
depends_on: Type.Optional(Type.Array(Type.String())),
|
|
313
|
+
outputs: Type.Optional(Type.Array(OutputSchema)),
|
|
314
|
+
iterate: Type.Optional(Type.Boolean({ description: "Replay node on each loop iteration" })),
|
|
315
|
+
worktree: Type.Optional(Type.Boolean({ description: "Run in a dedicated git worktree" })),
|
|
316
|
+
});
|
|
317
|
+
const FleetSchema = Type.Object({
|
|
318
|
+
fleet_name: Type.String({ description: "kebab-case" }),
|
|
319
|
+
type: Type.Literal("dag"),
|
|
320
|
+
config: Type.Optional(Type.Object({
|
|
321
|
+
max_concurrent: Type.Optional(Type.Number()),
|
|
322
|
+
model: Type.Optional(Type.String({ description: "Fleet-wide default model" })),
|
|
323
|
+
warn_cost_usd: Type.Optional(Type.Number()),
|
|
324
|
+
loop: Type.Optional(Type.Object({
|
|
325
|
+
gate: Type.Union([Type.Literal("reviewer"), Type.Literal("none")]),
|
|
326
|
+
max_iterations: Type.Number(),
|
|
327
|
+
lgtm_count: Type.Optional(Type.Number()),
|
|
328
|
+
})),
|
|
329
|
+
})),
|
|
330
|
+
workers: Type.Array(WorkerSchema, { minItems: 1 }),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
pi.registerTool({
|
|
334
|
+
name: "fleet_plan",
|
|
335
|
+
label: "Fleet Plan",
|
|
336
|
+
description:
|
|
337
|
+
"Validate a fleet DAG definition, create its fleet root, and return an ASCII preview. Does NOT launch. Call fleet_launch after the user confirms the preview. Model selection standard: cheap/fast models (e.g. gpt-5.4-mini, kimi-for-coding-highspeed) for trivial writers/validators; coding models (e.g. kimi-for-coding, gpt-5.5) for code-run workers; strong reasoning models (e.g. k3, gpt-5.6-sol) for reviewers/synthesizers. Set worker.model per node to override config.model.",
|
|
338
|
+
promptSnippet: "Plan a DAG-of-agents fleet from a fleet definition without launching it.",
|
|
339
|
+
parameters: Type.Object({
|
|
340
|
+
fleet: FleetSchema,
|
|
341
|
+
}),
|
|
342
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
343
|
+
const v = validateFleetSpec(params.fleet);
|
|
344
|
+
if (!v.ok) return textResult(`Invalid fleet:\n${v.errors.join("\n")}`);
|
|
345
|
+
|
|
346
|
+
const fleetRoot = fleetRootFor(ctx.cwd, v.spec.fleet_name);
|
|
347
|
+
const state = initFleetState(v.spec);
|
|
348
|
+
await ensureFleetGitignore(ctx.cwd);
|
|
349
|
+
await writePlanFiles(fleetRoot, v.spec, state);
|
|
350
|
+
active = { spec: v.spec, fleetRoot, state, killSwitch: { killed: false }, pauseSwitch: { paused: false }, running: false, costWarned: false };
|
|
351
|
+
updateWidget(ctx, active);
|
|
352
|
+
|
|
353
|
+
const dag = await dagPreview(v.spec, undefined, fleetRoot);
|
|
354
|
+
return textResult(`${dag}\n\nfleet root: ${fleetRoot}\ncall fleet_launch to start`, { fleetRoot, layers: v.layers });
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
pi.registerTool({
|
|
359
|
+
name: "fleet_launch",
|
|
360
|
+
label: "Fleet Launch",
|
|
361
|
+
description: "Launch the active planned fleet. Runs the DAG in the background and updates the live fleet widget. Pass skip_confirm: true to bypass the interactive confirmation (e.g. when the user already approved the plan or is running unattended).",
|
|
362
|
+
promptSnippet: "Launch the currently planned fleet after preview confirmation.",
|
|
363
|
+
parameters: Type.Object({
|
|
364
|
+
skip_confirm: Type.Optional(Type.Boolean({ description: "Skip the interactive launch confirmation dialog" })),
|
|
365
|
+
}),
|
|
366
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
367
|
+
if (!active) return textResult("no fleet planned yet");
|
|
368
|
+
if (active.running) return textResult("fleet already running");
|
|
369
|
+
const fleet = active;
|
|
370
|
+
|
|
371
|
+
if (fleet.spec.workers.some((w) => w.worktree === true)) {
|
|
372
|
+
if (!(await isInsideGitRepo(ctx.cwd))) {
|
|
373
|
+
return textResult(`worktree workers require a git repo; none found above ${ctx.cwd}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (ctx.hasUI && !params.skip_confirm) {
|
|
378
|
+
const ok = await ctx.ui.confirm("Launch fleet?", renderDag(fleet.spec));
|
|
379
|
+
if (!ok) return textResult("fleet launch aborted");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
await writeWorkerPrompts(fleet);
|
|
383
|
+
void startLoop(fleet, ctx, false);
|
|
384
|
+
return textResult("fleet launched");
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
pi.registerTool({
|
|
389
|
+
name: "fleet_status",
|
|
390
|
+
label: "Fleet Status",
|
|
391
|
+
description: "Show the current active fleet DAG and live widget lines.",
|
|
392
|
+
promptSnippet: "Show current active fleet status.",
|
|
393
|
+
parameters: Type.Object({}),
|
|
394
|
+
async execute() {
|
|
395
|
+
if (!active) return textResult("no fleet planned yet");
|
|
396
|
+
return textResult(await statusText(active));
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
pi.registerTool({
|
|
401
|
+
name: "fleet_kill",
|
|
402
|
+
label: "Fleet Kill",
|
|
403
|
+
description: "Request fleet-wide kill. Use target \"all\". Single-node kill is not supported in v1.",
|
|
404
|
+
promptSnippet: "Kill the active fleet with target \"all\".",
|
|
405
|
+
parameters: Type.Object({ target: Type.String({ description: "Use all. Node ids are not supported in v1." }) }),
|
|
406
|
+
async execute(_id, params) {
|
|
407
|
+
return textResult(await killFleet(params.target));
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
pi.registerTool({
|
|
412
|
+
name: "fleet_pause",
|
|
413
|
+
label: "Fleet Pause",
|
|
414
|
+
description: "Request a pause of the active running loop fleet. The pause takes effect at the next iteration boundary.",
|
|
415
|
+
promptSnippet: "Pause the active fleet at the next iteration boundary.",
|
|
416
|
+
parameters: Type.Object({}),
|
|
417
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
418
|
+
if (!active) return textResult("no fleet planned yet");
|
|
419
|
+
if (!active.spec.config.loop) return textResult("fleet has no loop; pause is a loop-fleet operation");
|
|
420
|
+
if (!active.running) return textResult("fleet not running");
|
|
421
|
+
active.pauseSwitch.paused = true;
|
|
422
|
+
active.state = { ...active.state, paused: true };
|
|
423
|
+
await writeState(active.fleetRoot, active.state);
|
|
424
|
+
updateWidget(ctx, active);
|
|
425
|
+
return textResult("pause requested (takes effect at next iteration boundary)");
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
pi.registerTool({
|
|
430
|
+
name: "fleet_resume",
|
|
431
|
+
label: "Fleet Resume",
|
|
432
|
+
description: "Resume a paused loop fleet from the current iteration.",
|
|
433
|
+
promptSnippet: "Resume the paused active fleet.",
|
|
434
|
+
parameters: Type.Object({}),
|
|
435
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
436
|
+
if (!active) return textResult("no fleet planned yet");
|
|
437
|
+
if (active.state.status !== "paused") return textResult("fleet is not paused");
|
|
438
|
+
if (active.running) return textResult("fleet already running");
|
|
439
|
+
active.pauseSwitch.paused = false;
|
|
440
|
+
void startLoop(active, ctx, true);
|
|
441
|
+
return textResult("fleet resumed");
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
pi.registerTool({
|
|
446
|
+
name: "fleet_relaunch",
|
|
447
|
+
label: "Fleet Relaunch",
|
|
448
|
+
description: "Relaunch a failed node and any blocked downstream dependents. Optionally override the worker model for this run.",
|
|
449
|
+
promptSnippet: "Relaunch a failed fleet node.",
|
|
450
|
+
parameters: Type.Object({
|
|
451
|
+
node_id: Type.String({ description: "Worker id to relaunch" }),
|
|
452
|
+
model: Type.Optional(Type.String({ description: "Optional model override for this run, e.g. gpt-5.4-mini" })),
|
|
453
|
+
}),
|
|
454
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
455
|
+
if (!active) return textResult("no fleet planned yet");
|
|
456
|
+
if (active.running) return textResult("fleet is running");
|
|
457
|
+
const fleet = active;
|
|
458
|
+
await currentState(fleet);
|
|
459
|
+
if (fleet.state.status === "completed") return textResult("fleet completed, nothing to relaunch");
|
|
460
|
+
const worker = fleet.spec.workers.find((w) => w.id === params.node_id);
|
|
461
|
+
if (!worker) return textResult(`unknown node "${params.node_id}"`);
|
|
462
|
+
const node = fleet.state.nodes[params.node_id];
|
|
463
|
+
const relaunchable: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed"]);
|
|
464
|
+
if (!node || !relaunchable.has(node.status)) {
|
|
465
|
+
return textResult(`node "${params.node_id}" status ${node?.status ?? "missing"} cannot be relaunched; must be failed, contract_failed, or killed`);
|
|
466
|
+
}
|
|
467
|
+
if (params.model) {
|
|
468
|
+
const resolved = resolveModelReference(ctx.modelRegistry, params.model);
|
|
469
|
+
if (!resolved.ok) return textResult(resolved.error);
|
|
470
|
+
const canonical = `${resolved.model.provider}/${resolved.model.id}`;
|
|
471
|
+
fleet.spec.workers = fleet.spec.workers.map((w) => w.id === params.node_id ? { ...w, model: canonical } : w);
|
|
472
|
+
await writeFile(join(fleet.fleetRoot, "fleet.json"), `${JSON.stringify(fleet.spec, null, 2)}\n`, "utf-8");
|
|
473
|
+
}
|
|
474
|
+
fleet.state = resetForRelaunch(fleet.state, fleet.spec, params.node_id);
|
|
475
|
+
await writeState(fleet.fleetRoot, fleet.state);
|
|
476
|
+
fleet.killSwitch.killed = false;
|
|
477
|
+
fleet.pauseSwitch.paused = false;
|
|
478
|
+
void startLoop(fleet, ctx, false, true);
|
|
479
|
+
return textResult(`fleet relaunch requested for ${params.node_id}`);
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
pi.registerTool({
|
|
484
|
+
name: "fleet_report",
|
|
485
|
+
label: "Fleet Report",
|
|
486
|
+
description: "Regenerate and return the active fleet markdown report from current state.",
|
|
487
|
+
promptSnippet: "Regenerate the active fleet report.",
|
|
488
|
+
parameters: Type.Object({}),
|
|
489
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
490
|
+
if (!active) return textResult("no fleet planned yet");
|
|
491
|
+
const state = await currentState(active);
|
|
492
|
+
const report = await writeReport({ spec: active.spec, state, fleetRoot: active.fleetRoot, repoCwd: ctx.cwd });
|
|
493
|
+
return textResult(report, { reportPath: join(active.fleetRoot, "report.md") });
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
pi.registerCommand("fleet", {
|
|
498
|
+
description: "Fleet commands: /fleet viz, /fleet status, /fleet clear, /fleet kill all, /fleet pause, /fleet resume, /fleet relaunch <node_id> [model]",
|
|
499
|
+
handler: async (args, ctx) => {
|
|
500
|
+
const [cmd, target] = args.trim().split(/\s+/);
|
|
501
|
+
if (!active) {
|
|
502
|
+
ctx.ui.notify("no fleet planned yet", "warning");
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (cmd === "viz") {
|
|
506
|
+
const lines = renderDag(active.spec, active.state).split("\n");
|
|
507
|
+
ctx.ui.setWidget("fleet", lines);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (cmd === "status" || cmd === "") {
|
|
511
|
+
ctx.ui.setWidget("fleet", buildWidgetLines(active.spec, active.state));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (cmd === "clear") {
|
|
515
|
+
ctx.ui.setWidget("fleet", []);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (cmd === "kill") {
|
|
519
|
+
const text = await killFleet(target ?? "");
|
|
520
|
+
ctx.ui.notify(text, text === "fleet kill requested" ? "warning" : "error");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (cmd === "pause") {
|
|
524
|
+
if (!active.spec.config.loop) {
|
|
525
|
+
ctx.ui.notify("fleet has no loop; pause is a loop-fleet operation", "warning");
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (!active.running) {
|
|
529
|
+
ctx.ui.notify("fleet not running", "warning");
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
active.pauseSwitch.paused = true;
|
|
533
|
+
active.state = { ...active.state, paused: true };
|
|
534
|
+
await writeState(active.fleetRoot, active.state);
|
|
535
|
+
updateWidget(ctx, active);
|
|
536
|
+
ctx.ui.notify("pause requested (takes effect at next iteration boundary)", "warning");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (cmd === "resume") {
|
|
540
|
+
if (active.state.status !== "paused") {
|
|
541
|
+
ctx.ui.notify("fleet is not paused", "warning");
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (active.running) {
|
|
545
|
+
ctx.ui.notify("fleet already running", "warning");
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
active.pauseSwitch.paused = false;
|
|
549
|
+
void startLoop(active, ctx, true);
|
|
550
|
+
ctx.ui.notify("fleet resumed", "info");
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (cmd === "relaunch") {
|
|
554
|
+
if (!target) {
|
|
555
|
+
ctx.ui.notify("usage: /fleet relaunch <node_id> [model]", "warning");
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (active.running) {
|
|
559
|
+
ctx.ui.notify("fleet is running", "warning");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
await currentState(active);
|
|
563
|
+
if (active.state.status === "completed") {
|
|
564
|
+
ctx.ui.notify("fleet completed, nothing to relaunch", "warning");
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const worker = active.spec.workers.find((w) => w.id === target);
|
|
568
|
+
if (!worker) {
|
|
569
|
+
ctx.ui.notify(`unknown node "${target}"`, "warning");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const node = active.state.nodes[target];
|
|
573
|
+
const relaunchable: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed"]);
|
|
574
|
+
if (!node || !relaunchable.has(node.status)) {
|
|
575
|
+
ctx.ui.notify(`node "${target}" status ${node?.status ?? "missing"} cannot be relaunched; must be failed, contract_failed, or killed`, "warning");
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const model = args.trim().split(/\s+/).slice(2).join(" ") || undefined;
|
|
579
|
+
if (model) {
|
|
580
|
+
const resolved = resolveModelReference(ctx.modelRegistry, model);
|
|
581
|
+
if (!resolved.ok) {
|
|
582
|
+
ctx.ui.notify(resolved.error, "error");
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const canonical = `${resolved.model.provider}/${resolved.model.id}`;
|
|
586
|
+
active.spec.workers = active.spec.workers.map((w) => w.id === target ? { ...w, model: canonical } : w);
|
|
587
|
+
await writeFile(join(active.fleetRoot, "fleet.json"), `${JSON.stringify(active.spec, null, 2)}\n`, "utf-8");
|
|
588
|
+
}
|
|
589
|
+
active.state = resetForRelaunch(active.state, active.spec, target);
|
|
590
|
+
await writeState(active.fleetRoot, active.state);
|
|
591
|
+
active.killSwitch.killed = false;
|
|
592
|
+
active.pauseSwitch.paused = false;
|
|
593
|
+
void startLoop(active, ctx, false, true);
|
|
594
|
+
ctx.ui.notify(`fleet relaunch requested for ${target}`, "info");
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
ctx.ui.notify("usage: /fleet viz | /fleet status | /fleet clear | /fleet kill all | /fleet pause | /fleet resume | /fleet relaunch <node_id> [model]", "warning");
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|