pi-agent-fleet 0.4.0 → 0.6.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 +23 -8
- package/examples/json-number-pipeline.json +35 -0
- package/package.json +2 -2
- package/src/canvas-client.tsx +184 -66
- package/src/canvas-layout.ts +83 -0
- package/src/canvas.ts +178 -86
- package/src/command.ts +56 -42
- package/src/contracts.ts +76 -20
- package/src/controller.ts +72 -7
- package/src/dag.ts +32 -7
- package/src/edits.ts +7 -8
- package/src/fleet-recovery.ts +83 -0
- package/src/fleet-store.ts +11 -2
- package/src/insert.ts +2 -8
- package/src/model-resolution.ts +27 -2
- package/src/preferences.ts +10 -1
- package/src/prompts.ts +21 -0
- package/src/report.ts +28 -1
- package/src/scheduler.ts +51 -5
- package/src/state.ts +17 -17
- package/src/tools.ts +70 -35
- package/src/types.ts +8 -0
- package/src/worktree.ts +6 -0
package/src/prompts.ts
CHANGED
|
@@ -17,6 +17,17 @@ export function buildWorkerPrompt(opts: {
|
|
|
17
17
|
const dependents = getDependents(spec, workerId);
|
|
18
18
|
const out: string[] = [];
|
|
19
19
|
|
|
20
|
+
out.push(
|
|
21
|
+
"## Autonomy contract (read first)",
|
|
22
|
+
"",
|
|
23
|
+
"You are an unattended fleet worker — there is no human watching this session and nobody will answer questions.",
|
|
24
|
+
"- Everything in this prompt is PRE-APPROVED. Do not ask for approval; do the work.",
|
|
25
|
+
"- Do NOT invoke any skill or workflow with a human approval gate (e.g. brainstorming hard-gates). Skip gated steps and execute the task directly.",
|
|
26
|
+
"- Do not end your turn with a question, a plan awaiting approval, or an 'Approve?' prompt. End your turn only when every REQUIRED output below exists on disk.",
|
|
27
|
+
"- Ambiguity is yours to resolve: decide, record the decision in your output, and continue.",
|
|
28
|
+
"",
|
|
29
|
+
);
|
|
30
|
+
|
|
20
31
|
const fleetTs = basename(fleetRoot);
|
|
21
32
|
|
|
22
33
|
out.push(`# Fleet worker: ${workerId}`, "", `Type: ${worker.type}`, "", `## Task`, "", worker.task, "");
|
|
@@ -100,6 +111,16 @@ export function buildWorkerPrompt(opts: {
|
|
|
100
111
|
} else {
|
|
101
112
|
for (const o of worker.outputs) {
|
|
102
113
|
out.push(`- ${o.path} (${o.kind}${o.required ? ", REQUIRED" : ", optional"})`);
|
|
114
|
+
if (o.kind === "json" && o.schema) {
|
|
115
|
+
const bits: string[] = [];
|
|
116
|
+
if (o.schema.required_keys && o.schema.required_keys.length > 0) {
|
|
117
|
+
bits.push(`must be a JSON object containing keys: ${o.schema.required_keys.join(", ")}`);
|
|
118
|
+
}
|
|
119
|
+
if (o.schema.number_keys && o.schema.number_keys.length > 0) {
|
|
120
|
+
bits.push(`these keys must be numbers or arrays of numbers: ${o.schema.number_keys.join(", ")}`);
|
|
121
|
+
}
|
|
122
|
+
if (bits.length > 0) out.push(` - ${bits.join("; ")}`);
|
|
123
|
+
}
|
|
103
124
|
}
|
|
104
125
|
out.push("");
|
|
105
126
|
}
|
package/src/report.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, join } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import type { FleetSpec, FleetState } from "./types.js";
|
|
@@ -72,6 +72,33 @@ export async function writeReport(opts: {
|
|
|
72
72
|
if (w.worktree) lines.push(`- fleet/${base}/${w.id}`);
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
lines.push("", "## Next steps", "");
|
|
76
|
+
const reportPath = join(fleetRoot, "report.md");
|
|
77
|
+
const failed = Object.entries(state.nodes).find(([, n]) => n.status === "failed" || n.status === "contract_failed");
|
|
78
|
+
const next = state.status === "planned" ? "fleet_launch"
|
|
79
|
+
: state.status === "running" ? "fleet_status, fleet_canvas, or fleet_kill <id>|all"
|
|
80
|
+
: failed ? `fleet_relaunch ${failed[0]}`
|
|
81
|
+
: state.status === "completed" ? `read report ${reportPath}`
|
|
82
|
+
: `inspect ${join(fleetRoot, "state.json")}`;
|
|
83
|
+
lines.push(`- next: ${next}`);
|
|
84
|
+
lines.push("", "## JSON outputs", "");
|
|
85
|
+
let anyJson = false;
|
|
86
|
+
for (const w of spec.workers) {
|
|
87
|
+
const n = state.nodes[w.id];
|
|
88
|
+
for (const out of n?.produced_outputs ?? []) {
|
|
89
|
+
if (!out.startsWith("output/") || !out.endsWith(".json") || out.includes("..")) continue;
|
|
90
|
+
anyJson = true;
|
|
91
|
+
let content: string;
|
|
92
|
+
try {
|
|
93
|
+
const raw = await readFile(join(fleetRoot, "workers", w.id, out), "utf-8");
|
|
94
|
+
content = raw.length > 4096 ? `${raw.slice(0, 4096)}\n... (truncated)` : raw;
|
|
95
|
+
} catch {
|
|
96
|
+
content = "(unreadable)";
|
|
97
|
+
}
|
|
98
|
+
lines.push(`### ${w.id}: ${out}`, "", "```json", content.trimEnd(), "```", "");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!anyJson) lines.push("(none)", "");
|
|
75
102
|
lines.push("", "## Code changes", "", "```", await gitDiffStat(repoCwd, state.created_at), "```", "");
|
|
76
103
|
lines.push("## Artifacts", "", `- state: ${join(fleetRoot, "state.json")}`,
|
|
77
104
|
`- sessions: ${join(fleetRoot, "workers", "<id>", "session.jsonl")}`, "");
|
package/src/scheduler.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, rm } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { verifyOutputs } from "./contracts.js";
|
|
4
|
-
import { archiveIteration, initFleetState, patchNode, resetForIteration, snapshotIteration, writeState } from "./state.js";
|
|
3
|
+
import { contractFailureNote, verifyOutputs } from "./contracts.js";
|
|
4
|
+
import { archiveIteration, initFleetState, patchNode, relaunchResetIds, 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
7
|
import { commitWorktree, createWorktree, prepareIntegratorWorktree } from "./worktree.js";
|
|
@@ -20,6 +20,7 @@ export interface RunFleetOpts {
|
|
|
20
20
|
killSwitch?: { killed: boolean };
|
|
21
21
|
pauseSwitch?: { paused: boolean };
|
|
22
22
|
nodeKills?: ReadonlySet<string>;
|
|
23
|
+
relaunchRequests?: Set<string>;
|
|
23
24
|
resumeFrom?: FleetState;
|
|
24
25
|
continuePass?: boolean;
|
|
25
26
|
onIterationEnd?: (snap: IterationSnapshot) => void;
|
|
@@ -111,6 +112,31 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
111
112
|
|
|
112
113
|
const runPass = async (): Promise<void> => {
|
|
113
114
|
while (true) {
|
|
115
|
+
// apply queued relaunch requests (lost-wakeup fix, issue #1 bug 2)
|
|
116
|
+
if (opts.relaunchRequests && opts.relaunchRequests.size > 0) {
|
|
117
|
+
for (const id of [...opts.relaunchRequests]) {
|
|
118
|
+
opts.relaunchRequests.delete(id);
|
|
119
|
+
const n = state.nodes[id];
|
|
120
|
+
if (!n || !FAILED.has(n.status)) continue;
|
|
121
|
+
for (const rid of relaunchResetIds(spec, state, id)) {
|
|
122
|
+
const rn = state.nodes[rid];
|
|
123
|
+
if (!rn) continue;
|
|
124
|
+
if (rid === id && !FAILED.has(rn.status)) continue;
|
|
125
|
+
if (rid !== id && rn.status !== "blocked") continue;
|
|
126
|
+
await patch(rid, {
|
|
127
|
+
status: "pending",
|
|
128
|
+
started_at: undefined,
|
|
129
|
+
ended_at: undefined,
|
|
130
|
+
turns: 0,
|
|
131
|
+
tokens: 0,
|
|
132
|
+
cost_usd_estimate: 0,
|
|
133
|
+
produced_outputs: [],
|
|
134
|
+
contract_result: undefined,
|
|
135
|
+
status_note: undefined,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
114
140
|
// auto-initialize workers inserted into the spec after the run started
|
|
115
141
|
for (const w of spec.workers) {
|
|
116
142
|
if (!state.nodes[w.id]) {
|
|
@@ -144,6 +170,15 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
144
170
|
}
|
|
145
171
|
break;
|
|
146
172
|
}
|
|
173
|
+
// honor kill requests for not-yet-running nodes (incl. blocked, issue #1 bug 3)
|
|
174
|
+
for (const w of spec.workers) {
|
|
175
|
+
const n = state.nodes[w.id];
|
|
176
|
+
if (!n) continue;
|
|
177
|
+
if (!opts.nodeKills?.has(w.id)) continue;
|
|
178
|
+
if (n.status === "pending" || n.status === "ready" || n.status === "blocked") {
|
|
179
|
+
await patch(w.id, { status: "killed", ended_at: new Date().toISOString() });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
147
182
|
// dispatch ready
|
|
148
183
|
const activeCount = running.size;
|
|
149
184
|
let slots = spec.config.max_concurrent - activeCount;
|
|
@@ -214,6 +249,7 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
214
249
|
}
|
|
215
250
|
}
|
|
216
251
|
|
|
252
|
+
const dispatchMs = Date.now();
|
|
217
253
|
await patch(w.id, { status: "running", started_at: new Date().toISOString() });
|
|
218
254
|
const p = opts.spawn(w.id).then(async (res) => {
|
|
219
255
|
if (opts.killSwitch?.killed) return;
|
|
@@ -250,7 +286,10 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
250
286
|
workerDir: `${fleetRoot}/workers/${w.id}`,
|
|
251
287
|
repoCwd: repoCwdFor(w.id),
|
|
252
288
|
outputs: w.outputs,
|
|
289
|
+
notBeforeMs: dispatchMs,
|
|
253
290
|
});
|
|
291
|
+
const costUnknown = res.tokens > 0 && res.cost === 0;
|
|
292
|
+
const costNote = costUnknown ? `cost unavailable: no pricing for model (${res.tokens} tokens used)` : undefined;
|
|
254
293
|
await patch(w.id, {
|
|
255
294
|
status: contract.ok ? "completed" : "contract_failed",
|
|
256
295
|
ended_at: new Date().toISOString(),
|
|
@@ -258,7 +297,8 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
258
297
|
tokens: res.tokens,
|
|
259
298
|
cost_usd_estimate: res.cost ?? 0,
|
|
260
299
|
contract_result: contract,
|
|
261
|
-
produced_outputs: contract.checks.filter((c) => c.ok).map((c) => c.path),
|
|
300
|
+
produced_outputs: contract.checks.filter((c) => c.ok || c.actualPath).map((c) => c.path),
|
|
301
|
+
status_note: contract.ok ? costNote : [contractFailureNote(contract.checks), costNote].filter(Boolean).join(" · "),
|
|
262
302
|
});
|
|
263
303
|
if (contract.ok) {
|
|
264
304
|
const note = await opts.onNodeCompleted?.(w.id);
|
|
@@ -279,8 +319,14 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
279
319
|
await Promise.allSettled([...running]);
|
|
280
320
|
};
|
|
281
321
|
|
|
322
|
+
const runPassUntilDrained = async () => {
|
|
323
|
+
do {
|
|
324
|
+
await runPass();
|
|
325
|
+
} while (opts.relaunchRequests && opts.relaunchRequests.size > 0);
|
|
326
|
+
};
|
|
327
|
+
|
|
282
328
|
if (!loop) {
|
|
283
|
-
await
|
|
329
|
+
await runPassUntilDrained();
|
|
284
330
|
const anyFailed = spec.workers.some((w) =>
|
|
285
331
|
["failed", "contract_failed"].includes(state.nodes[w.id]?.status ?? ""));
|
|
286
332
|
const finalStatus = opts.killSwitch?.killed ? "killed" : anyFailed ? "failed" : "completed";
|
|
@@ -310,7 +356,7 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
310
356
|
|
|
311
357
|
await opts.prepareIteration?.(n, state);
|
|
312
358
|
|
|
313
|
-
await
|
|
359
|
+
await runPassUntilDrained();
|
|
314
360
|
|
|
315
361
|
let verdict: Verdict | null = null;
|
|
316
362
|
let verdictBody: string | null = null;
|
package/src/state.ts
CHANGED
|
@@ -121,34 +121,34 @@ export function patchNode(
|
|
|
121
121
|
return { ...state, nodes, cost_usd_estimate: cost };
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
export function
|
|
124
|
+
export function relaunchResetIds(spec: FleetSpec, state: FleetState, nodeId: string): string[] {
|
|
125
125
|
if (!state.nodes[nodeId]) throw new Error(`unknown node "${nodeId}"`);
|
|
126
|
-
|
|
127
126
|
const dependents: Record<string, string[]> = {};
|
|
128
127
|
for (const w of spec.workers) {
|
|
129
128
|
for (const dep of w.depends_on) {
|
|
130
129
|
(dependents[dep] ??= []).push(w.id);
|
|
131
130
|
}
|
|
132
131
|
}
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
132
|
+
const out = [nodeId];
|
|
133
|
+
const seen = new Set(out);
|
|
134
|
+
const queue = [nodeId];
|
|
135
|
+
while (queue.length) {
|
|
136
|
+
for (const d of dependents[queue.shift()!] ?? []) {
|
|
137
|
+
if (seen.has(d)) continue;
|
|
138
|
+
seen.add(d);
|
|
139
|
+
if (state.nodes[d]?.status === "blocked") out.push(d);
|
|
140
|
+
queue.push(d);
|
|
141
141
|
}
|
|
142
|
-
}
|
|
143
|
-
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
144
145
|
|
|
146
|
+
export function resetForRelaunch(state: FleetState, spec: FleetSpec, nodeId: string): FleetState {
|
|
145
147
|
const fresh: NodeState = { status: "pending", turns: 0, tokens: 0, cost_usd_estimate: 0, produced_outputs: [] };
|
|
146
148
|
const nodes = { ...state.nodes };
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
nodes[id] = fresh;
|
|
151
|
-
}
|
|
149
|
+
for (const id of relaunchResetIds(spec, state, nodeId)) {
|
|
150
|
+
if (id !== nodeId && state.nodes[id]?.status !== "blocked") continue;
|
|
151
|
+
nodes[id] = fresh;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
const cost = fleetCost({ ...state, nodes });
|
package/src/tools.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import { writeFile } from "node:fs/promises";
|
|
2
1
|
import { join } from "node:path";
|
|
3
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
3
|
import { Type } from "typebox";
|
|
5
4
|
import { validateFleetSpec } from "./dag.js";
|
|
6
5
|
import { insertWorkers } from "./insert.js";
|
|
7
6
|
import { loadPreferences, mergeFleetConfig } from "./preferences.js";
|
|
8
|
-
import { activeFleet, currentState, dagPreview, ensureCanvas, killFleet,
|
|
7
|
+
import { activeFleet, currentState, dagPreview, ensureCanvas, killFleet, requestRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
|
|
9
8
|
import { openInBrowser, listFleetRoots } from "./canvas.js";
|
|
10
9
|
import { editConfig, editNode, type ConfigEditKey, type NodeEditKey } from "./edits.js";
|
|
11
10
|
import { ensureFleetGitignore, fleetRootFor, isInsideGitRepo, writePlanFiles, writeWorkerPrompts } from "./fleet-store.js";
|
|
12
|
-
import {
|
|
11
|
+
import { recoverLatestFleet } from "./fleet-recovery.js";
|
|
12
|
+
import { listModelRefs, validateFleetModels } from "./model-resolution.js";
|
|
13
13
|
import { runFleetDesign, slugifyFleetName } from "./planner.js";
|
|
14
14
|
import { writeReport } from "./report.js";
|
|
15
|
-
import { initFleetState,
|
|
15
|
+
import { initFleetState, writeState } from "./state.js";
|
|
16
16
|
import { renderDag } from "./viz.js";
|
|
17
17
|
|
|
18
18
|
export function textResult(text: string, details: Record<string, unknown> = {}) {
|
|
@@ -27,6 +27,10 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
27
27
|
Type.Literal("verdict"), Type.Literal("json"), Type.Literal("yaml"),
|
|
28
28
|
]),
|
|
29
29
|
required: Type.Optional(Type.Boolean()),
|
|
30
|
+
schema: Type.Optional(Type.Object({
|
|
31
|
+
required_keys: Type.Optional(Type.Array(Type.String(), { description: "JSON object keys that must be present" })),
|
|
32
|
+
number_keys: Type.Optional(Type.Array(Type.String(), { description: "JSON object keys that must be numbers or arrays of numbers" })),
|
|
33
|
+
}, { description: "Optional JSON object shape; only valid with kind json" })),
|
|
30
34
|
});
|
|
31
35
|
const EffortSchema = Type.Union([
|
|
32
36
|
Type.Literal("off"), Type.Literal("minimal"), Type.Literal("low"),
|
|
@@ -67,7 +71,7 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
67
71
|
name: "fleet_plan",
|
|
68
72
|
label: "Fleet Plan",
|
|
69
73
|
description:
|
|
70
|
-
"Validate a fleet DAG definition, create its fleet root, and return an ASCII preview. Does NOT launch. PREREQUISITE: if the user's request is prose requirements or a goal rather than an explicit fleet definition, you MUST call fleet_design first and pass its drafted definition here — do not hand-write the fleet JSON yourself. Present the preview to the user; call fleet_launch only after they explicitly confirm. Choose models by task difficulty: cheap/fast models for trivial writers and validators, mid-tier coding models for code-run workers, strongest reasoning models for reviewers and synthesizers. When several models fit a tier, vary providers across nodes instead of defaulting to one family. Set worker.model per node to override config.model. All model refs are validated against the live registry — planning fails if any model is unavailable.",
|
|
74
|
+
"Validate a fleet DAG definition, create its fleet root, and return an ASCII preview. Does NOT launch. PREREQUISITE: if the user's request is prose requirements or a goal rather than an explicit fleet definition, you MUST call fleet_design first and pass its drafted definition here — do not hand-write the fleet JSON yourself. Present the preview to the user; call fleet_launch only after they explicitly confirm. Choose models by task difficulty: cheap/fast models for trivial writers and validators, mid-tier coding models for code-run workers, strongest reasoning models for reviewers and synthesizers. When several models fit a tier, vary providers across nodes instead of defaulting to one family. Set worker.model per node to override config.model. All model refs are validated against the live registry — planning fails if any model is unavailable. Call fleet_models first if you do not know exact provider/model IDs.",
|
|
71
75
|
promptSnippet: "Plan a DAG-of-agents fleet from a fleet definition without launching it.",
|
|
72
76
|
parameters: Type.Object({
|
|
73
77
|
fleet: FleetSchema,
|
|
@@ -84,11 +88,12 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
84
88
|
const state = initFleetState(v.spec);
|
|
85
89
|
await ensureFleetGitignore(ctx.cwd);
|
|
86
90
|
await writePlanFiles(fleetRoot, v.spec, state);
|
|
87
|
-
const active = activeFleet.current = { spec: v.spec, fleetRoot, state, killSwitch: { killed: false }, pauseSwitch: { paused: false }, running: false, costWarned: false, sessions: new Map(), killedNodes: new Set() };
|
|
91
|
+
const active = activeFleet.current = { spec: v.spec, fleetRoot, state, killSwitch: { killed: false }, pauseSwitch: { paused: false }, running: false, costWarned: false, sessions: new Map(), killedNodes: new Set(), relaunchRequests: new Set(), widgetVisible: false };
|
|
88
92
|
updateWidget(ctx, active);
|
|
89
93
|
|
|
90
94
|
const dag = await dagPreview(v.spec, undefined, fleetRoot);
|
|
91
|
-
|
|
95
|
+
const canvas = ctx.hasUI ? await ensureCanvas(ctx) : undefined;
|
|
96
|
+
return textResult(`${dag}\n\nfleet root: ${fleetRoot}${canvas ? `\nfleet canvas: ${canvas.url}` : ""}\n\nShow this preview to the user. Call fleet_launch only after they explicitly confirm.`, { fleetRoot, layers: v.layers, canvasUrl: canvas?.url });
|
|
92
97
|
},
|
|
93
98
|
});
|
|
94
99
|
|
|
@@ -117,14 +122,32 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
117
122
|
}
|
|
118
123
|
}
|
|
119
124
|
|
|
125
|
+
if (fleet.state.status !== "planned" || Object.values(fleet.state.nodes).some((n) => n.status !== "pending")) {
|
|
126
|
+
return textResult(
|
|
127
|
+
`fleet already started (${fleet.state.status}); use fleet_continue to resume pending work, fleet_relaunch <node_id> to retry a failed node, or start a new fleet with fleet_plan`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
120
131
|
if (ctx.hasUI && !params.skip_confirm) {
|
|
121
132
|
const ok = await ctx.ui.confirm("Launch fleet?", renderDag(fleet.spec));
|
|
122
133
|
if (!ok) return textResult("fleet launch aborted");
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
await writeWorkerPrompts(fleet);
|
|
137
|
+
const canvas = ctx.hasUI ? await ensureCanvas(ctx) : undefined;
|
|
126
138
|
void startLoop(fleet, ctx, false);
|
|
127
|
-
return textResult(
|
|
139
|
+
return textResult(`fleet launched${canvas ? `\n\nfleet canvas: ${canvas.url}` : ""}`, { canvasUrl: canvas?.url });
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
pi.registerTool({
|
|
144
|
+
name: "fleet_models",
|
|
145
|
+
label: "Fleet Models",
|
|
146
|
+
description: "List available model refs (provider/id) from the live registry. Call this before fleet_plan if you do not know exact provider/model IDs.",
|
|
147
|
+
promptSnippet: "List available fleet model refs.",
|
|
148
|
+
parameters: Type.Object({}),
|
|
149
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
150
|
+
return textResult(`available models:\n${listModelRefs(ctx.modelRegistry).join("\n")}`);
|
|
128
151
|
},
|
|
129
152
|
});
|
|
130
153
|
|
|
@@ -134,8 +157,9 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
134
157
|
description: "Show the current active fleet DAG and live widget lines.",
|
|
135
158
|
promptSnippet: "Show current active fleet status.",
|
|
136
159
|
parameters: Type.Object({}),
|
|
137
|
-
async execute() {
|
|
138
|
-
const active = activeFleet.current;
|
|
160
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
161
|
+
const active = activeFleet.current ?? await recoverLatestFleet(ctx.cwd);
|
|
162
|
+
if (active) activeFleet.current ??= active;
|
|
139
163
|
if (!active) return textResult("no fleet planned yet");
|
|
140
164
|
return textResult(await statusText(active));
|
|
141
165
|
},
|
|
@@ -147,8 +171,8 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
147
171
|
description: "Request a fleet-wide kill (target \"all\") or kill a single node by worker id. Killing a running node aborts its session; killing a pending node marks it killed at the next dispatch pass. Killed nodes can be revived with fleet_relaunch.",
|
|
148
172
|
promptSnippet: "Kill the whole fleet or a single node by worker id.",
|
|
149
173
|
parameters: Type.Object({ target: Type.String({ description: "all or a worker id" }) }),
|
|
150
|
-
async execute(_id, params) {
|
|
151
|
-
return textResult(await killFleet(params.target));
|
|
174
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
175
|
+
return textResult(await killFleet(params.target, ctx.cwd));
|
|
152
176
|
},
|
|
153
177
|
});
|
|
154
178
|
|
|
@@ -188,10 +212,37 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
188
212
|
},
|
|
189
213
|
});
|
|
190
214
|
|
|
215
|
+
pi.registerTool({
|
|
216
|
+
name: "fleet_continue",
|
|
217
|
+
label: "Fleet Continue",
|
|
218
|
+
description: "Continue the active fleet from its current state without restarting completed nodes. Dispatches pending and ready nodes and unblocks downstream as dependencies complete. Use this after a failed/killed fleet to resume work safely.",
|
|
219
|
+
promptSnippet: "Continue the active fleet from current state.",
|
|
220
|
+
parameters: Type.Object({}),
|
|
221
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
222
|
+
const active = activeFleet.current ?? await recoverLatestFleet(ctx.cwd);
|
|
223
|
+
if (active) activeFleet.current ??= active;
|
|
224
|
+
if (!active) return textResult("no fleet planned yet");
|
|
225
|
+
if (active.running) return textResult("fleet already running");
|
|
226
|
+
await currentState(active);
|
|
227
|
+
if (active.state.status === "completed") return textResult("fleet completed, nothing to continue");
|
|
228
|
+
if (active.state.status === "paused") return textResult("fleet is paused; use fleet_resume for paused loop fleets");
|
|
229
|
+
if (active.state.status === "planned" && Object.values(active.state.nodes).every((n) => n.status === "pending")) {
|
|
230
|
+
return textResult("fleet has not started; use fleet_launch");
|
|
231
|
+
}
|
|
232
|
+
active.killSwitch.killed = false;
|
|
233
|
+
active.pauseSwitch.paused = false;
|
|
234
|
+
active.state = { ...active.state, status: "running", paused: false };
|
|
235
|
+
await writeState(active.fleetRoot, active.state);
|
|
236
|
+
await writeWorkerPrompts(active);
|
|
237
|
+
void startLoop(active, ctx, false, true);
|
|
238
|
+
return textResult("fleet continue requested");
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
191
242
|
pi.registerTool({
|
|
192
243
|
name: "fleet_relaunch",
|
|
193
244
|
label: "Fleet Relaunch",
|
|
194
|
-
description: "Relaunch a failed node and any blocked downstream dependents. Optionally override the worker model for this run.",
|
|
245
|
+
description: "Relaunch a failed node and any blocked downstream dependents. Works while the fleet is running (queued for the next scheduler pass) and after it stops. Optionally override the worker model for this run.",
|
|
195
246
|
promptSnippet: "Relaunch a failed fleet node.",
|
|
196
247
|
parameters: Type.Object({
|
|
197
248
|
node_id: Type.String({ description: "Worker id to relaunch" }),
|
|
@@ -200,29 +251,12 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
200
251
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
201
252
|
const active = activeFleet.current;
|
|
202
253
|
if (!active) return textResult("no fleet planned yet");
|
|
203
|
-
if (active.running) return textResult("fleet is running");
|
|
204
254
|
const fleet = active;
|
|
205
255
|
await currentState(fleet);
|
|
206
256
|
if (fleet.state.status === "completed") return textResult("fleet completed, nothing to relaunch");
|
|
207
|
-
const
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
const relaunchable: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed"]);
|
|
211
|
-
if (!node || !relaunchable.has(node.status)) {
|
|
212
|
-
return textResult(`node "${params.node_id}" status ${node?.status ?? "missing"} cannot be relaunched; must be failed, contract_failed, or killed`);
|
|
213
|
-
}
|
|
214
|
-
if (params.model) {
|
|
215
|
-
const resolved = resolveModelReference(ctx.modelRegistry, params.model);
|
|
216
|
-
if (!resolved.ok) return textResult(resolved.error);
|
|
217
|
-
const canonical = `${resolved.model.provider}/${resolved.model.id}`;
|
|
218
|
-
fleet.spec.workers = fleet.spec.workers.map((w) => w.id === params.node_id ? { ...w, model: canonical } : w);
|
|
219
|
-
await writeFile(join(fleet.fleetRoot, "fleet.json"), `${JSON.stringify(fleet.spec, null, 2)}\n`, "utf-8");
|
|
220
|
-
}
|
|
221
|
-
fleet.state = resetForRelaunch(fleet.state, fleet.spec, params.node_id);
|
|
222
|
-
await writeState(fleet.fleetRoot, fleet.state);
|
|
223
|
-
prepareRelaunch(fleet, params.node_id);
|
|
224
|
-
void startLoop(fleet, ctx, false, true);
|
|
225
|
-
return textResult(`fleet relaunch requested for ${params.node_id}`);
|
|
257
|
+
const result = await requestRelaunch(fleet, params.node_id, params.model, ctx.modelRegistry);
|
|
258
|
+
if (result.startNow) void startLoop(fleet, ctx, false, true);
|
|
259
|
+
return textResult(result.message);
|
|
226
260
|
},
|
|
227
261
|
});
|
|
228
262
|
|
|
@@ -289,7 +323,8 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
289
323
|
promptSnippet: "Regenerate the active fleet report.",
|
|
290
324
|
parameters: Type.Object({}),
|
|
291
325
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
292
|
-
const active = activeFleet.current;
|
|
326
|
+
const active = activeFleet.current ?? await recoverLatestFleet(ctx.cwd);
|
|
327
|
+
if (active) activeFleet.current ??= active;
|
|
293
328
|
if (!active) return textResult("no fleet planned yet");
|
|
294
329
|
const state = await currentState(active);
|
|
295
330
|
const report = await writeReport({ spec: active.spec, state, fleetRoot: active.fleetRoot, repoCwd: ctx.cwd });
|
|
@@ -300,7 +335,7 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
300
335
|
pi.registerTool({
|
|
301
336
|
name: "fleet_edit",
|
|
302
337
|
label: "Fleet Edit",
|
|
303
|
-
description: "Edit the active fleet: a pending node's model, effort, or task — or fleet config (max_concurrent, warn_cost_usd, model, effort) when node_id is omitted. Changes persist to fleet.json and apply to nodes
|
|
338
|
+
description: "Edit the active fleet: a pending or relaunchable node's model, effort, or task — or fleet config (max_concurrent, warn_cost_usd, model, effort) when node_id is omitted. Changes persist to fleet.json and apply immediately. Edits to running or completed nodes are refused; pending, blocked, failed, contract_failed, and killed nodes can be edited (blocked nodes have not started — nothing to invalidate).",
|
|
304
339
|
promptSnippet: "Edit a pending fleet node or fleet config.",
|
|
305
340
|
parameters: Type.Object({
|
|
306
341
|
node_id: Type.Optional(Type.String({ description: "Worker id to edit; omit for fleet config edits" })),
|
package/src/types.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
export type OutputKind = "markdown" | "file-exists" | "verdict" | "json" | "yaml";
|
|
2
2
|
|
|
3
|
+
export interface JsonOutputSchema {
|
|
4
|
+
required_keys?: string[];
|
|
5
|
+
number_keys?: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
3
8
|
export interface ContractOutput {
|
|
4
9
|
path: string;
|
|
5
10
|
kind: OutputKind;
|
|
6
11
|
required: boolean;
|
|
12
|
+
schema?: JsonOutputSchema;
|
|
7
13
|
}
|
|
8
14
|
|
|
9
15
|
export type WorkerType = "research" | "code-run" | "reviewer" | "write" | "read-only";
|
|
@@ -64,6 +70,8 @@ export interface ContractCheck {
|
|
|
64
70
|
required: boolean;
|
|
65
71
|
ok: boolean;
|
|
66
72
|
error?: string;
|
|
73
|
+
actualPath?: string;
|
|
74
|
+
firstLines?: string;
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
export interface ContractResult {
|
package/src/worktree.ts
CHANGED
|
@@ -17,6 +17,7 @@ export async function createWorktree(opts: CreateWorktreeOpts): Promise<string>
|
|
|
17
17
|
const branch = `fleet/${opts.fleetName}/${opts.nodeId}`;
|
|
18
18
|
await mkdir(path, { recursive: true });
|
|
19
19
|
await removeWorktree(path, opts.baseRepo);
|
|
20
|
+
await removeBranch(opts.baseRepo, branch);
|
|
20
21
|
await execFileP("git", ["worktree", "add", "-b", branch, path], { cwd: opts.baseRepo });
|
|
21
22
|
return path;
|
|
22
23
|
}
|
|
@@ -55,6 +56,7 @@ export async function prepareIntegratorWorktree(
|
|
|
55
56
|
): Promise<{ path: string; ok: boolean; conflict?: string }> {
|
|
56
57
|
const path = join(opts.fleetRoot, "worktrees", "fleet-integrator");
|
|
57
58
|
await removeWorktree(path, opts.baseRepo);
|
|
59
|
+
await removeBranch(opts.baseRepo, `fleet/${opts.fleetName}/fleet-integrator`);
|
|
58
60
|
await mkdir(path, { recursive: true });
|
|
59
61
|
await execFileP("git", ["worktree", "add", "-b", `fleet/${opts.fleetName}/fleet-integrator`, path], {
|
|
60
62
|
cwd: opts.baseRepo,
|
|
@@ -73,3 +75,7 @@ export async function prepareIntegratorWorktree(
|
|
|
73
75
|
export async function removeWorktree(path: string, baseRepo: string): Promise<void> {
|
|
74
76
|
await execFileP("git", ["worktree", "remove", "--force", path], { cwd: baseRepo }).catch(() => {});
|
|
75
77
|
}
|
|
78
|
+
|
|
79
|
+
export async function removeBranch(baseRepo: string, branch: string): Promise<void> {
|
|
80
|
+
await execFileP("git", ["branch", "-D", branch], { cwd: baseRepo }).catch(() => {});
|
|
81
|
+
}
|