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/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 runPass();
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 runPass();
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 resetForRelaunch(state: FleetState, spec: FleetSpec, nodeId: string): FleetState {
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 downstream = new Set<string>();
135
- const walk = (id: string) => {
136
- for (const d of dependents[id] ?? []) {
137
- if (!downstream.has(d)) {
138
- downstream.add(d);
139
- walk(d);
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
- walk(nodeId);
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
- nodes[nodeId] = fresh;
148
- for (const id of downstream) {
149
- if (state.nodes[id].status === "blocked") {
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, prepareRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
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 { resolveModelReference, validateFleetModels } from "./model-resolution.js";
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, resetForRelaunch, writeState } from "./state.js";
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
- return textResult(`${dag}\n\nfleet root: ${fleetRoot}\nShow this preview to the user. Call fleet_launch only after they explicitly confirm.`, { fleetRoot, layers: v.layers });
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("fleet launched");
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 worker = fleet.spec.workers.find((w) => w.id === params.node_id);
208
- if (!worker) return textResult(`unknown node "${params.node_id}"`);
209
- const node = fleet.state.nodes[params.node_id];
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 not yet dispatched. Refuses edits to nodes already running or terminal.",
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
+ }