pi-agent-fleet 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -23
- package/assets/canvas-node-peek.png +0 -0
- package/assets/canvas.png +0 -0
- package/examples/json-number-pipeline.json +35 -0
- package/package.json +11 -3
- package/src/canvas-client.tsx +820 -0
- package/src/canvas.ts +740 -401
- package/src/command.ts +56 -14
- package/src/contracts.ts +68 -16
- package/src/controller.ts +33 -6
- package/src/dag.ts +85 -2
- package/src/edits.ts +8 -9
- package/src/fleet-recovery.ts +73 -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 +10 -0
- package/src/report.ts +28 -1
- package/src/scheduler.ts +116 -2
- package/src/tools.ts +67 -14
- package/src/types.ts +8 -0
- package/src/worktree.ts +81 -0
package/src/command.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import { writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { activeFleet, currentState, ensureCanvas, killFleet, prepareRelaunch, startLoop, stopCanvas, updateWidget } from "./controller.js";
|
|
2
|
+
import { activeFleet, currentState, ensureCanvas, killFleet, prepareRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
|
|
3
|
+
import { persistFleetJson, writeWorkerPrompts } from "./fleet-store.js";
|
|
5
4
|
import { openInBrowser, listFleetRoots } from "./canvas.js";
|
|
6
5
|
import { insertWorkers } from "./insert.js";
|
|
7
6
|
import { editConfig, editNode, type ConfigEditKey, type NodeEditKey } from "./edits.js";
|
|
8
|
-
import { resolveModelReference } from "./model-resolution.js";
|
|
7
|
+
import { listModelRefs, resolveModelReference } from "./model-resolution.js";
|
|
9
8
|
import { clearPreference, loadPreferences, PREFERENCE_KEYS, savePreferences, setPreference } from "./preferences.js";
|
|
9
|
+
import { recoverLatestFleet } from "./fleet-recovery.js";
|
|
10
10
|
import { resetForRelaunch, writeState } from "./state.js";
|
|
11
11
|
import { buildWidgetLines } from "./ui.js";
|
|
12
12
|
import { renderDag } from "./viz.js";
|
|
13
13
|
|
|
14
14
|
export function registerFleetCommand(pi: ExtensionAPI): void {
|
|
15
15
|
pi.registerCommand("fleet", {
|
|
16
|
-
description: "Fleet commands: /fleet viz, /fleet status, /fleet canvas [stop], /fleet configure [show|set k v], /fleet add <json>, /fleet edit <node_id>|config ..., /fleet clear, /fleet kill all|<node_id>, /fleet pause, /fleet resume, /fleet relaunch <node_id> [model]",
|
|
16
|
+
description: "Fleet commands: /fleet viz, /fleet status, /fleet models, /fleet canvas [stop], /fleet configure [show|set k v], /fleet add <json>, /fleet edit <node_id>|config ..., /fleet clear, /fleet kill all|<node_id>, /fleet pause, /fleet resume, /fleet continue, /fleet relaunch <node_id> [model]",
|
|
17
17
|
handler: async (args, ctx) => {
|
|
18
18
|
const [cmd, target] = args.trim().split(/\s+/);
|
|
19
19
|
if (cmd === "configure") {
|
|
@@ -32,7 +32,7 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
|
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
34
|
const prefs = await loadPreferences();
|
|
35
|
-
const r = setPreference(prefs, key, value);
|
|
35
|
+
const r = setPreference(prefs, key, value, ctx.modelRegistry);
|
|
36
36
|
if (!r.ok) {
|
|
37
37
|
ctx.ui.notify(r.error, "error");
|
|
38
38
|
return;
|
|
@@ -50,15 +50,19 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
|
|
|
50
50
|
const key = field.split(":")[0] as (typeof PREFERENCE_KEYS)[number];
|
|
51
51
|
const input = await ctx.ui.input(`${key} (current: ${prefs[key] ?? "—"}):`, "empty clears");
|
|
52
52
|
if (input === undefined) break;
|
|
53
|
+
let err: string | undefined;
|
|
53
54
|
const next = input.trim().length === 0
|
|
54
55
|
? clearPreference(prefs, key)
|
|
55
56
|
: (() => {
|
|
56
|
-
const r = setPreference(prefs, key, input.trim());
|
|
57
|
-
if (!r.ok)
|
|
57
|
+
const r = setPreference(prefs, key, input.trim(), ctx.modelRegistry);
|
|
58
|
+
if (!r.ok) {
|
|
59
|
+
err = r.error;
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
58
62
|
return r.prefs;
|
|
59
63
|
})();
|
|
60
64
|
if (next === undefined) {
|
|
61
|
-
ctx.ui.notify(`invalid value for ${key}`, "error");
|
|
65
|
+
ctx.ui.notify(err ?? `invalid value for ${key}`, "error");
|
|
62
66
|
continue;
|
|
63
67
|
}
|
|
64
68
|
await savePreferences(next);
|
|
@@ -87,26 +91,36 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
|
|
|
87
91
|
ctx.ui.notify(`fleet canvas: ${url}`, "info");
|
|
88
92
|
return;
|
|
89
93
|
}
|
|
90
|
-
|
|
94
|
+
if (cmd === "models") {
|
|
95
|
+
ctx.ui.notify(`available models:\n${listModelRefs(ctx.modelRegistry).join("\n")}`, "info");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const active = activeFleet.current ?? await recoverLatestFleet(ctx.cwd);
|
|
99
|
+
if (active) activeFleet.current ??= active;
|
|
91
100
|
if (!active) {
|
|
92
101
|
ctx.ui.notify("no fleet planned yet", "warning");
|
|
93
102
|
return;
|
|
94
103
|
}
|
|
95
104
|
if (cmd === "viz") {
|
|
105
|
+
active.widgetVisible = true;
|
|
96
106
|
const lines = renderDag(active.spec, active.state).split("\n");
|
|
97
107
|
ctx.ui.setWidget("fleet", lines);
|
|
108
|
+
ctx.ui.notify("fleet widget visible; fleet canvas link: " + (await ensureCanvas(ctx)).url, "info");
|
|
98
109
|
return;
|
|
99
110
|
}
|
|
100
111
|
if (cmd === "status" || cmd === "") {
|
|
101
|
-
|
|
112
|
+
const server = await ensureCanvas(ctx);
|
|
113
|
+
ctx.ui.notify(`${await statusText(active)}\n\nfleet canvas: ${server.url}`, "info");
|
|
102
114
|
return;
|
|
103
115
|
}
|
|
104
116
|
if (cmd === "clear") {
|
|
117
|
+
active.widgetVisible = false;
|
|
105
118
|
ctx.ui.setWidget("fleet", []);
|
|
119
|
+
ctx.ui.notify("fleet widget hidden", "info");
|
|
106
120
|
return;
|
|
107
121
|
}
|
|
108
122
|
if (cmd === "kill") {
|
|
109
|
-
const text = await killFleet(target ?? "");
|
|
123
|
+
const text = await killFleet(target ?? "", ctx.cwd);
|
|
110
124
|
const severity = text.includes("kill") && !text.startsWith("unknown") && !text.includes("already") ? "warning" : "error";
|
|
111
125
|
ctx.ui.notify(text, severity);
|
|
112
126
|
return;
|
|
@@ -141,6 +155,33 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
|
|
|
141
155
|
ctx.ui.notify("fleet resumed", "info");
|
|
142
156
|
return;
|
|
143
157
|
}
|
|
158
|
+
if (cmd === "continue") {
|
|
159
|
+
if (active.running) {
|
|
160
|
+
ctx.ui.notify("fleet already running", "warning");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
await currentState(active);
|
|
164
|
+
if (active.state.status === "completed") {
|
|
165
|
+
ctx.ui.notify("fleet completed, nothing to continue", "warning");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (active.state.status === "paused") {
|
|
169
|
+
ctx.ui.notify("fleet is paused; use /fleet resume for paused loop fleets", "warning");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (active.state.status === "planned" && Object.values(active.state.nodes).every((n) => n.status === "pending")) {
|
|
173
|
+
ctx.ui.notify("fleet has not started; use /fleet launch", "warning");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
active.killSwitch.killed = false;
|
|
177
|
+
active.pauseSwitch.paused = false;
|
|
178
|
+
active.state = { ...active.state, status: "running", paused: false };
|
|
179
|
+
await writeState(active.fleetRoot, active.state);
|
|
180
|
+
await writeWorkerPrompts(active);
|
|
181
|
+
void startLoop(active, ctx, false, true);
|
|
182
|
+
ctx.ui.notify("fleet continue requested", "info");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
144
185
|
if (cmd === "relaunch") {
|
|
145
186
|
if (!target) {
|
|
146
187
|
ctx.ui.notify("usage: /fleet relaunch <node_id> [model]", "warning");
|
|
@@ -175,10 +216,11 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
|
|
|
175
216
|
}
|
|
176
217
|
const canonical = `${resolved.model.provider}/${resolved.model.id}`;
|
|
177
218
|
active.spec.workers = active.spec.workers.map((w) => w.id === target ? { ...w, model: canonical } : w);
|
|
178
|
-
await
|
|
219
|
+
await persistFleetJson(active);
|
|
179
220
|
}
|
|
180
221
|
active.state = resetForRelaunch(active.state, active.spec, target);
|
|
181
222
|
await writeState(active.fleetRoot, active.state);
|
|
223
|
+
await writeWorkerPrompts(active);
|
|
182
224
|
prepareRelaunch(active, target);
|
|
183
225
|
void startLoop(active, ctx, false, true);
|
|
184
226
|
ctx.ui.notify(`fleet relaunch requested for ${target}`, "info");
|
|
@@ -241,7 +283,7 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
|
|
|
241
283
|
if (r.ok) updateWidget(ctx, active);
|
|
242
284
|
return;
|
|
243
285
|
}
|
|
244
|
-
ctx.ui.notify("usage: /fleet viz | /fleet status | /fleet canvas [stop] | /fleet configure [show|set k v] | /fleet add <json> | /fleet edit <node_id>|config ... | /fleet clear | /fleet kill all|<node_id> | /fleet pause | /fleet resume | /fleet relaunch <node_id> [model]", "warning");
|
|
286
|
+
ctx.ui.notify("usage: /fleet viz | /fleet status | /fleet models | /fleet canvas [stop] | /fleet configure [show|set k v] | /fleet add <json> | /fleet edit <node_id>|config ... | /fleet clear | /fleet kill all|<node_id> | /fleet pause | /fleet resume | /fleet continue | /fleet relaunch <node_id> [model]", "warning");
|
|
245
287
|
},
|
|
246
288
|
});
|
|
247
289
|
}
|
package/src/contracts.ts
CHANGED
|
@@ -4,53 +4,91 @@ import type { ContractCheck, ContractOutput, ContractResult, Verdict } from "./t
|
|
|
4
4
|
|
|
5
5
|
export const VERDICT_RE = /^verdict:\s*(lgtm|iterate|escalate)\s*$/mi;
|
|
6
6
|
|
|
7
|
+
// Leading metadata lines that a markdown file may place before its first heading.
|
|
8
|
+
const MARKDOWN_METADATA_RE = /^(status|verdict):\s*(approved|needs-revision|escalate|lgtm|iterate)\s*$/i;
|
|
9
|
+
|
|
7
10
|
function resolvePath(workerDir: string, repoCwd: string, p: string): string {
|
|
8
11
|
if (isAbsolute(p)) return p;
|
|
9
12
|
return p.startsWith("output/") ? join(workerDir, p) : join(repoCwd, p);
|
|
10
13
|
}
|
|
11
14
|
|
|
15
|
+
function firstLines(content: string, n = 5): string {
|
|
16
|
+
return content
|
|
17
|
+
.split("\n")
|
|
18
|
+
.slice(0, n)
|
|
19
|
+
.map((l) => l.trimEnd())
|
|
20
|
+
.join(" / ");
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
async function checkOne(workerDir: string, repoCwd: string, o: ContractOutput): Promise<ContractCheck> {
|
|
13
24
|
const full = resolvePath(workerDir, repoCwd, o.path);
|
|
14
|
-
const
|
|
25
|
+
const base: Omit<ContractCheck, "ok" | "error"> = { path: o.path, kind: o.kind, required: o.required, actualPath: full };
|
|
26
|
+
const fail = (error: string): ContractCheck => ({ ...base, ok: false, error });
|
|
15
27
|
let content: string;
|
|
16
28
|
try {
|
|
17
29
|
const s = await stat(full);
|
|
18
30
|
if (o.kind === "file-exists") {
|
|
19
31
|
return s.size > 0
|
|
20
|
-
? {
|
|
32
|
+
? { ...base, ok: true }
|
|
21
33
|
: fail("empty file");
|
|
22
34
|
}
|
|
23
35
|
content = await readFile(full, "utf-8");
|
|
24
36
|
} catch {
|
|
25
|
-
return
|
|
37
|
+
return { path: o.path, kind: o.kind, required: o.required, ok: false, error: "file not found" };
|
|
26
38
|
}
|
|
39
|
+
|
|
27
40
|
switch (o.kind) {
|
|
28
41
|
case "markdown": {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
const meaningful = content.split("\n").filter((l) => l.trim().length > 0);
|
|
43
|
+
const first10 = meaningful.slice(0, 10);
|
|
44
|
+
const heading = first10.find((l) => l.trim().startsWith("#"));
|
|
45
|
+
if (heading) {
|
|
46
|
+
return { ...base, ok: true };
|
|
47
|
+
}
|
|
48
|
+
// Check whether the only blocker was a leading metadata line like Status: ...
|
|
49
|
+
const metadataFirst = first10[0]?.match(MARKDOWN_METADATA_RE);
|
|
50
|
+
return { ...fail(metadataFirst ? "no markdown heading after leading metadata line" : "no markdown heading"), firstLines: firstLines(content) };
|
|
33
51
|
}
|
|
34
52
|
case "verdict": {
|
|
35
53
|
const m = content.match(VERDICT_RE);
|
|
36
|
-
if (!m) return
|
|
54
|
+
if (!m) return { ...base, ok: false, error: "no verdict line", firstLines: firstLines(content) };
|
|
37
55
|
const body = content.slice(content.indexOf(m[0]) + m[0].length).trim();
|
|
38
56
|
return body.length > 0
|
|
39
|
-
? {
|
|
40
|
-
:
|
|
57
|
+
? { ...base, ok: true }
|
|
58
|
+
: { ...base, ok: false, error: "verdict line without body", firstLines: firstLines(content) };
|
|
41
59
|
}
|
|
42
|
-
case "json":
|
|
60
|
+
case "json": {
|
|
61
|
+
let parsed: unknown;
|
|
43
62
|
try {
|
|
44
|
-
JSON.parse(content);
|
|
45
|
-
return { path: o.path, kind: o.kind, required: o.required, ok: true };
|
|
63
|
+
parsed = JSON.parse(content);
|
|
46
64
|
} catch (e) {
|
|
47
|
-
return
|
|
65
|
+
return { ...base, ok: false, error: `json parse error: ${(e as Error).message}`, firstLines: firstLines(content) };
|
|
48
66
|
}
|
|
67
|
+
if (o.schema) {
|
|
68
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
69
|
+
return { ...base, ok: false, error: "json schema requires an object", firstLines: firstLines(content) };
|
|
70
|
+
}
|
|
71
|
+
const obj = parsed as Record<string, unknown>;
|
|
72
|
+
for (const key of o.schema.required_keys ?? []) {
|
|
73
|
+
if (!Object.hasOwn(obj, key)) {
|
|
74
|
+
return { ...base, ok: false, error: `missing required key "${key}"`, firstLines: firstLines(content) };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const key of o.schema.number_keys ?? []) {
|
|
78
|
+
const v = obj[key];
|
|
79
|
+
const ok = typeof v === "number" || (Array.isArray(v) && v.every((x) => typeof x === "number"));
|
|
80
|
+
if (!ok) {
|
|
81
|
+
return { ...base, ok: false, error: `key "${key}" must be a number or number[]`, firstLines: firstLines(content) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { ...base, ok: true };
|
|
86
|
+
}
|
|
49
87
|
case "yaml": {
|
|
50
88
|
const bad = content.split("\n").some((l) => l.includes("\t"));
|
|
51
89
|
return content.trim().length > 0 && !bad
|
|
52
|
-
? {
|
|
53
|
-
:
|
|
90
|
+
? { ...base, ok: true }
|
|
91
|
+
: { ...base, ok: false, error: "empty or invalid yaml" };
|
|
54
92
|
}
|
|
55
93
|
default:
|
|
56
94
|
return fail(`unknown kind`);
|
|
@@ -76,3 +114,17 @@ export async function verifyOutputs(opts: {
|
|
|
76
114
|
const verdict_body = content.slice(content.indexOf(m[0]) + m[0].length).trim();
|
|
77
115
|
return verdict_body.length > 0 ? { ok, checks, verdict, verdict_body } : { ok, checks };
|
|
78
116
|
}
|
|
117
|
+
|
|
118
|
+
/** Format a concise, actionable note for the first failed required contract. */
|
|
119
|
+
export function contractFailureNote(checks: ContractCheck[]): string {
|
|
120
|
+
const failed = checks.find((c) => c.required && !c.ok);
|
|
121
|
+
if (!failed) return "contract failed";
|
|
122
|
+
const parts = [
|
|
123
|
+
`${failed.path}: ${failed.error}`,
|
|
124
|
+
`actual: ${failed.actualPath ?? "(not found)"}`,
|
|
125
|
+
];
|
|
126
|
+
if (failed.firstLines) {
|
|
127
|
+
parts.push(`first lines: ${failed.firstLines}`);
|
|
128
|
+
}
|
|
129
|
+
return parts.join("; ");
|
|
130
|
+
}
|
package/src/controller.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
5
5
|
import { writeWorkerPrompts } from "./fleet-store.js";
|
|
6
|
+
import { recoverLatestFleet } from "./fleet-recovery.js";
|
|
6
7
|
import { resolveModelReference, type ModelRegistryLike } from "./model-resolution.js";
|
|
7
8
|
import { insertWorkers } from "./insert.js";
|
|
8
9
|
import { writeReport } from "./report.js";
|
|
@@ -25,6 +26,7 @@ export interface ActiveFleet {
|
|
|
25
26
|
costWarned?: boolean;
|
|
26
27
|
sessions: Map<string, AgentSessionLike>;
|
|
27
28
|
killedNodes: Set<string>;
|
|
29
|
+
widgetVisible?: boolean;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export interface ActiveFleetCell {
|
|
@@ -34,7 +36,7 @@ export interface ActiveFleetCell {
|
|
|
34
36
|
export const activeFleet: ActiveFleetCell = { current: undefined };
|
|
35
37
|
|
|
36
38
|
export function updateWidget(ctx: ExtensionContext, fleet: ActiveFleet, spinnerFrame?: number): void {
|
|
37
|
-
if (ctx.hasUI) ctx.ui.setWidget("fleet", buildWidgetLines(fleet.spec, fleet.state, { spinnerFrame }));
|
|
39
|
+
if (ctx.hasUI && fleet.widgetVisible) ctx.ui.setWidget("fleet", buildWidgetLines(fleet.spec, fleet.state, { spinnerFrame }));
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export function startSpinner(ctx: ExtensionContext, fleet: ActiveFleet, intervalMs = 150): () => void {
|
|
@@ -60,7 +62,14 @@ export async function currentState(fleet: ActiveFleet): Promise<FleetState> {
|
|
|
60
62
|
|
|
61
63
|
export async function statusText(fleet: ActiveFleet): Promise<string> {
|
|
62
64
|
const state = await currentState(fleet);
|
|
63
|
-
|
|
65
|
+
const reportPath = join(fleet.fleetRoot, "report.md");
|
|
66
|
+
const failed = Object.entries(state.nodes).find(([, n]) => n.status === "failed" || n.status === "contract_failed");
|
|
67
|
+
const next = state.status === "planned" ? "next: fleet_launch"
|
|
68
|
+
: state.status === "running" ? "next: fleet_status, fleet_canvas, or fleet_kill <id>|all"
|
|
69
|
+
: failed ? `next: fleet_relaunch ${failed[0]}`
|
|
70
|
+
: state.status === "completed" ? `next: read report ${reportPath}`
|
|
71
|
+
: `next: inspect ${join(fleet.fleetRoot, "state.json")}`;
|
|
72
|
+
return `${renderDag(fleet.spec, state)}\n\nreport: ${reportPath}\n${next}`;
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
export async function dagPreview(spec: FleetSpec, state: FleetState | undefined, fleetRoot: string): Promise<string> {
|
|
@@ -166,12 +175,15 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
166
175
|
const prompt = await readFile(join(fleet.fleetRoot, "workers", nodeId, "prompt.md"), "utf-8");
|
|
167
176
|
const sessionDir = join(fleet.fleetRoot, "workers", nodeId);
|
|
168
177
|
const effort = worker.effort ?? fleet.spec.config.effort ?? "medium";
|
|
178
|
+
const worktreeCwd = worker?.worktree || worker?.id === "fleet-integrator"
|
|
179
|
+
? join(fleet.fleetRoot, "worktrees", nodeId)
|
|
180
|
+
: ctx.cwd;
|
|
169
181
|
try {
|
|
170
182
|
return await runWorker({
|
|
171
183
|
nodeId,
|
|
172
184
|
worker: workerWithResolvedModel(worker, resolvedModel),
|
|
173
185
|
prompt,
|
|
174
|
-
repoCwd:
|
|
186
|
+
repoCwd: worktreeCwd,
|
|
175
187
|
sessionDir,
|
|
176
188
|
thinkingLevel: effort,
|
|
177
189
|
sessionFactory: resolvedModel ? sessionFactoryForModel(resolvedModel) : undefined,
|
|
@@ -200,9 +212,12 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
200
212
|
const state = await runFleet({
|
|
201
213
|
spec: fleet.spec,
|
|
202
214
|
fleetRoot: fleet.fleetRoot,
|
|
215
|
+
baseRepo: ctx.cwd,
|
|
203
216
|
repoCwd: (nodeId) => {
|
|
204
217
|
const worker = fleet.spec.workers.find((w) => w.id === nodeId);
|
|
205
|
-
return worker?.worktree
|
|
218
|
+
return worker?.worktree || worker?.id === "fleet-integrator"
|
|
219
|
+
? join(fleet.fleetRoot, "worktrees", nodeId)
|
|
220
|
+
: ctx.cwd;
|
|
206
221
|
},
|
|
207
222
|
spawn,
|
|
208
223
|
killSwitch: fleet.killSwitch,
|
|
@@ -257,9 +272,21 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
257
272
|
}
|
|
258
273
|
}
|
|
259
274
|
|
|
260
|
-
export async function killFleet(target: string): Promise<string> {
|
|
261
|
-
const active = activeFleet.current;
|
|
275
|
+
export async function killFleet(target: string, cwd?: string): Promise<string> {
|
|
276
|
+
const active = activeFleet.current ?? (cwd ? await recoverLatestFleet(cwd) : undefined);
|
|
277
|
+
if (active) activeFleet.current ??= active;
|
|
262
278
|
if (!active) return "no fleet planned yet";
|
|
279
|
+
if (!active.running) {
|
|
280
|
+
try {
|
|
281
|
+
active.state = await readState(active.fleetRoot);
|
|
282
|
+
} catch {
|
|
283
|
+
// keep in-memory state
|
|
284
|
+
}
|
|
285
|
+
if (active.state.status === "running") {
|
|
286
|
+
const where = target === "all" ? "fleet" : `node "${target}"`;
|
|
287
|
+
return `${where} not killed: fleet state on disk is "running" — it appears to be running in another live session; kill it there (this session holds no live scheduler, so a kill here would silently no-op)`;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
263
290
|
if (target === "all") {
|
|
264
291
|
active.killSwitch.killed = true;
|
|
265
292
|
return "fleet kill requested";
|
package/src/dag.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FleetSpec, GateKind, LoopConfig, OutputKind, ThinkingLevelName, WorkerSpec, WorkerType } from "./types.js";
|
|
1
|
+
import type { ContractOutput, FleetSpec, GateKind, JsonOutputSchema, LoopConfig, OutputKind, ThinkingLevelName, WorkerSpec, WorkerType } from "./types.js";
|
|
2
2
|
import { THINKING_LEVELS } from "./types.js";
|
|
3
3
|
|
|
4
4
|
export class CycleError extends Error {
|
|
@@ -49,6 +49,46 @@ 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
|
+
export function findRepoOutputOwnershipConflicts(workers: WorkerSpec[]): string[] {
|
|
53
|
+
const claims = new Map<string, string[]>();
|
|
54
|
+
for (const w of workers) {
|
|
55
|
+
for (const o of w.outputs) {
|
|
56
|
+
if (!o.path.startsWith("output/")) {
|
|
57
|
+
const list = claims.get(o.path) ?? [];
|
|
58
|
+
list.push(w.id);
|
|
59
|
+
claims.set(o.path, list);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const errors: string[] = [];
|
|
64
|
+
const byId = new Map(workers.map((w) => [w.id, w]));
|
|
65
|
+
for (const [path, ids] of claims) {
|
|
66
|
+
if (ids.length < 2) continue;
|
|
67
|
+
for (let i = 0; i < ids.length; i++) {
|
|
68
|
+
for (let j = i + 1; j < ids.length; j++) {
|
|
69
|
+
const a = ids[i], b = ids[j];
|
|
70
|
+
const aBeforeB = byId.get(a)!.depends_on.includes(b);
|
|
71
|
+
const bBeforeA = byId.get(b)!.depends_on.includes(a);
|
|
72
|
+
if (!aBeforeB && !bBeforeA) {
|
|
73
|
+
errors.push(`repo output ownership conflict: "${path}" claimed by "${a}" and "${b}" without ordered handoff`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return errors;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hasIntegratorPath(workers: WorkerSpec[]): boolean {
|
|
82
|
+
const worktreeIds = new Set(workers.filter((w) => w.worktree).map((w) => w.id));
|
|
83
|
+
if (worktreeIds.size < 2) return true;
|
|
84
|
+
return workers.some((w) => {
|
|
85
|
+
if (w.worktree) return false;
|
|
86
|
+
const deps = new Set(w.depends_on);
|
|
87
|
+
for (const id of worktreeIds) if (!deps.has(id)) return false;
|
|
88
|
+
return true;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
export function validateFleetSpec(
|
|
53
93
|
raw: unknown,
|
|
54
94
|
): { ok: true; spec: FleetSpec; layers: string[][] } | { ok: false; errors: string[] } {
|
|
@@ -91,6 +131,21 @@ export function validateFleetSpec(
|
|
|
91
131
|
errors.push(`worker "${id}": output path must be relative and stay within the repo`);
|
|
92
132
|
}
|
|
93
133
|
}
|
|
134
|
+
if (o.schema !== undefined) {
|
|
135
|
+
if (o.kind !== "json") {
|
|
136
|
+
errors.push(`worker "${id}": output schema only allowed with kind "json"`);
|
|
137
|
+
} else if (typeof o.schema !== "object" || o.schema === null || Array.isArray(o.schema)) {
|
|
138
|
+
errors.push(`worker "${id}": output schema must be an object`);
|
|
139
|
+
} else {
|
|
140
|
+
const s = o.schema as Record<string, unknown>;
|
|
141
|
+
for (const name of ["required_keys", "number_keys"] as const) {
|
|
142
|
+
const keys = s[name];
|
|
143
|
+
if (keys !== undefined && (!Array.isArray(keys) || keys.some((k) => typeof k !== "string" || k.length === 0))) {
|
|
144
|
+
errors.push(`worker "${id}": schema.${name} must be an array of non-empty strings`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
94
149
|
}
|
|
95
150
|
const wEffort = typeof w.effort === "string" ? w.effort as ThinkingLevelName : undefined;
|
|
96
151
|
if (wEffort !== undefined && !THINKING_LEVELS.includes(wEffort)) {
|
|
@@ -103,7 +158,18 @@ export function validateFleetSpec(
|
|
|
103
158
|
model: typeof w.model === "string" ? w.model : undefined,
|
|
104
159
|
effort: wEffort,
|
|
105
160
|
depends_on: Array.isArray(w.depends_on) ? (w.depends_on as string[]) : [],
|
|
106
|
-
outputs: outputs.map((o) =>
|
|
161
|
+
outputs: outputs.map((o) => {
|
|
162
|
+
const out: ContractOutput = { path: String(o.path), kind: o.kind as OutputKind, required: o.required !== false };
|
|
163
|
+
const s = o.schema;
|
|
164
|
+
if (o.kind === "json" && typeof s === "object" && s !== null && !Array.isArray(s)) {
|
|
165
|
+
const rec = s as Record<string, unknown>;
|
|
166
|
+
const schema: JsonOutputSchema = {};
|
|
167
|
+
if (Array.isArray(rec.required_keys)) schema.required_keys = rec.required_keys as string[];
|
|
168
|
+
if (Array.isArray(rec.number_keys)) schema.number_keys = rec.number_keys as string[];
|
|
169
|
+
out.schema = schema;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}),
|
|
107
173
|
iterate: w.iterate !== false,
|
|
108
174
|
worktree: w.worktree === true,
|
|
109
175
|
});
|
|
@@ -115,6 +181,23 @@ export function validateFleetSpec(
|
|
|
115
181
|
}
|
|
116
182
|
}
|
|
117
183
|
|
|
184
|
+
const worktreeIds = new Set(workers.filter((w) => w.worktree).map((w) => w.id));
|
|
185
|
+
if (worktreeIds.size >= 2) {
|
|
186
|
+
if (!workers.some((w) => w.id === "fleet-integrator")) {
|
|
187
|
+
workers.push({
|
|
188
|
+
id: "fleet-integrator",
|
|
189
|
+
type: "code-run",
|
|
190
|
+
task: `Merge worktree branches in order: ${[...worktreeIds].join(", ")}. Verify the combined repo is consistent and commit if needed.`,
|
|
191
|
+
depends_on: [...worktreeIds],
|
|
192
|
+
outputs: [],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (!hasIntegratorPath(workers)) {
|
|
196
|
+
errors.push("multiple worktree workers require an integrator that depends on all worktree workers");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
errors.push(...findRepoOutputOwnershipConflicts(workers));
|
|
200
|
+
|
|
118
201
|
let loopConfig: LoopConfig | undefined;
|
|
119
202
|
if (cfg.loop !== undefined && cfg.loop !== null) {
|
|
120
203
|
if (typeof cfg.loop !== "object" || Array.isArray(cfg.loop)) {
|
package/src/edits.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { rename, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { ActiveFleet } from "./controller.js";
|
|
4
|
+
import { persistFleetJson } from "./fleet-store.js";
|
|
4
5
|
import { resolveModelReference, type ModelRegistryLike } from "./model-resolution.js";
|
|
5
6
|
import { buildWorkerPrompt } from "./prompts.js";
|
|
7
|
+
import type { NodeStatus, ThinkingLevelName } from "./types.js";
|
|
6
8
|
import { THINKING_LEVELS } from "./types.js";
|
|
7
|
-
import type { ThinkingLevelName } from "./types.js";
|
|
8
9
|
|
|
9
10
|
export type NodeEditKey = "model" | "effort" | "task";
|
|
10
11
|
export type ConfigEditKey = "max_concurrent" | "warn_cost_usd" | "model" | "effort";
|
|
@@ -14,9 +15,7 @@ export interface EditResult {
|
|
|
14
15
|
message: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
await writeFile(join(fleet.fleetRoot, "fleet.json"), `${JSON.stringify(fleet.spec, null, 2)}\n`, "utf-8");
|
|
19
|
-
}
|
|
18
|
+
const EDITABLE_NODE_STATUSES: ReadonlySet<NodeStatus> = new Set(["pending", "ready", "failed", "contract_failed", "killed"]);
|
|
20
19
|
|
|
21
20
|
export async function editNode(
|
|
22
21
|
fleet: ActiveFleet,
|
|
@@ -28,9 +27,9 @@ export async function editNode(
|
|
|
28
27
|
const worker = fleet.spec.workers.find((w) => w.id === nodeId);
|
|
29
28
|
const node = fleet.state.nodes[nodeId];
|
|
30
29
|
if (!worker || !node) return { ok: false, message: `unknown node "${nodeId}"` };
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
if (!EDITABLE_NODE_STATUSES.has(node.status)) {
|
|
31
|
+
return { ok: false, message: `node "${nodeId}" is ${node.status}; only pending, failed, contract_failed, or killed nodes can be edited` };
|
|
32
|
+
}
|
|
34
33
|
switch (key) {
|
|
35
34
|
case "model": {
|
|
36
35
|
const r = resolveModelReference(registry, value);
|
|
@@ -63,7 +62,7 @@ export async function editNode(
|
|
|
63
62
|
default:
|
|
64
63
|
return { ok: false, message: `unknown node edit key "${String(key)}" (keys: model, effort, task)` };
|
|
65
64
|
}
|
|
66
|
-
await
|
|
65
|
+
await persistFleetJson(fleet);
|
|
67
66
|
return { ok: true, message: `node "${nodeId}" ${key} updated` };
|
|
68
67
|
}
|
|
69
68
|
|
|
@@ -102,6 +101,6 @@ export async function editConfig(
|
|
|
102
101
|
default:
|
|
103
102
|
return { ok: false, message: `unknown config key "${String(key)}" (keys: max_concurrent, warn_cost_usd, model, effort)` };
|
|
104
103
|
}
|
|
105
|
-
await
|
|
104
|
+
await persistFleetJson(fleet);
|
|
106
105
|
return { ok: true, message: `config.${key} updated` };
|
|
107
106
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ActiveFleet } from "./controller.js";
|
|
4
|
+
import { readState } from "./state.js";
|
|
5
|
+
import type { FleetSpec, FleetState } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export interface FleetRootInfo {
|
|
8
|
+
name: string;
|
|
9
|
+
root: string;
|
|
10
|
+
status: string;
|
|
11
|
+
created_at: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function readDiskFleet(fleetRoot: string): Promise<ActiveFleet> {
|
|
15
|
+
const spec = JSON.parse(await readFile(join(fleetRoot, "fleet.json"), "utf-8")) as FleetSpec;
|
|
16
|
+
const state = await readState(fleetRoot);
|
|
17
|
+
return {
|
|
18
|
+
spec,
|
|
19
|
+
fleetRoot,
|
|
20
|
+
state,
|
|
21
|
+
killSwitch: { killed: false },
|
|
22
|
+
pauseSwitch: { paused: false },
|
|
23
|
+
running: false,
|
|
24
|
+
sessions: new Map(),
|
|
25
|
+
killedNodes: new Set(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function listFleetRoots(cwd: string): Promise<FleetRootInfo[]> {
|
|
30
|
+
const base = join(cwd, ".fleet");
|
|
31
|
+
let entries: string[];
|
|
32
|
+
try {
|
|
33
|
+
entries = await readdir(base);
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const out: FleetRootInfo[] = [];
|
|
38
|
+
for (const name of entries) {
|
|
39
|
+
const root = join(base, name);
|
|
40
|
+
try {
|
|
41
|
+
const s = await stat(join(root, "fleet.json"));
|
|
42
|
+
if (!s.isFile()) continue;
|
|
43
|
+
const state = JSON.parse(await readFile(join(root, "state.json"), "utf-8")) as Partial<FleetState>;
|
|
44
|
+
out.push({
|
|
45
|
+
name,
|
|
46
|
+
root,
|
|
47
|
+
status: typeof state.status === "string" ? state.status : "unknown",
|
|
48
|
+
created_at: typeof state.created_at === "string" ? state.created_at : new Date(s.mtimeMs).toISOString(),
|
|
49
|
+
});
|
|
50
|
+
} catch {
|
|
51
|
+
// not a fleet root (no fleet.json) or unreadable state — skip or mark unknown
|
|
52
|
+
try {
|
|
53
|
+
await stat(join(root, "fleet.json"));
|
|
54
|
+
out.push({ name, root, status: "unknown", created_at: "" });
|
|
55
|
+
} catch {
|
|
56
|
+
// not a fleet root
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
out.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function recoverLatestFleet(cwd: string): Promise<ActiveFleet | undefined> {
|
|
65
|
+
const roots = await listFleetRoots(cwd);
|
|
66
|
+
const latest = roots.find((r) => r.status !== "unknown") ?? roots[0];
|
|
67
|
+
if (!latest) return undefined;
|
|
68
|
+
try {
|
|
69
|
+
return await readDiskFleet(latest.root);
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/fleet-store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { buildWorkerPrompt } from "./prompts.js";
|
|
4
4
|
import { writeState } from "./state.js";
|
|
@@ -47,9 +47,18 @@ export async function writePlanFiles(fleetRoot: string, spec: FleetSpec, state:
|
|
|
47
47
|
await writeState(fleetRoot, state);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export async function persistFleetJson(fleet: { fleetRoot: string; spec: FleetSpec }): Promise<void> {
|
|
51
|
+
const path = join(fleet.fleetRoot, "fleet.json");
|
|
52
|
+
const tmp = join(fleet.fleetRoot, `.fleet.json.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
53
|
+
await writeFile(tmp, `${JSON.stringify(fleet.spec, null, 2)}\n`, "utf-8");
|
|
54
|
+
await rename(tmp, path);
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
export async function writeWorkerPrompts(fleet: { spec: FleetSpec; state: FleetState; fleetRoot: string }): Promise<void> {
|
|
51
58
|
await Promise.all(fleet.spec.workers.map(async (w) => {
|
|
59
|
+
const dir = join(fleet.fleetRoot, "workers", w.id);
|
|
60
|
+
await mkdir(dir, { recursive: true });
|
|
52
61
|
const prompt = buildWorkerPrompt({ spec: fleet.spec, state: fleet.state, workerId: w.id, fleetRoot: fleet.fleetRoot });
|
|
53
|
-
await writeFile(join(
|
|
62
|
+
await writeFile(join(dir, "prompt.md"), prompt, "utf-8");
|
|
54
63
|
}));
|
|
55
64
|
}
|
package/src/insert.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { ActiveFleet } from "./controller.js";
|
|
4
4
|
import { validateFleetSpec } from "./dag.js";
|
|
5
|
+
import { persistFleetJson } from "./fleet-store.js";
|
|
5
6
|
import { resolveModelReference, type ModelRegistryLike } from "./model-resolution.js";
|
|
6
7
|
import { buildWorkerPrompt } from "./prompts.js";
|
|
7
8
|
import { writeState } from "./state.js";
|
|
@@ -12,13 +13,6 @@ export interface InsertResult {
|
|
|
12
13
|
inserted?: string[];
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
async function persistFleetJson(fleet: ActiveFleet): Promise<void> {
|
|
16
|
-
const path = join(fleet.fleetRoot, "fleet.json");
|
|
17
|
-
const tmp = join(fleet.fleetRoot, `.fleet.json.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
18
|
-
await writeFile(tmp, `${JSON.stringify(fleet.spec, null, 2)}\n`, "utf-8");
|
|
19
|
-
await rename(tmp, path);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
16
|
async function insertWorkersSerialized(
|
|
23
17
|
fleet: ActiveFleet,
|
|
24
18
|
raw: unknown,
|