pi-agent-fleet 0.4.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 +23 -8
- package/examples/json-number-pipeline.json +35 -0
- package/package.json +2 -2
- package/src/canvas-client.tsx +169 -18
- package/src/canvas.ts +173 -85
- package/src/command.ts +56 -14
- package/src/contracts.ts +68 -16
- package/src/controller.ts +25 -4
- package/src/dag.ts +32 -7
- 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 +3 -2
- package/src/tools.ts +67 -14
- package/src/types.ts +8 -0
- package/src/worktree.ts +6 -0
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> {
|
|
@@ -263,9 +272,21 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
263
272
|
}
|
|
264
273
|
}
|
|
265
274
|
|
|
266
|
-
export async function killFleet(target: string): Promise<string> {
|
|
267
|
-
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;
|
|
268
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
|
+
}
|
|
269
290
|
if (target === "all") {
|
|
270
291
|
active.killSwitch.killed = true;
|
|
271
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,10 +49,9 @@ export function getDependents(spec: FleetSpec, nodeId: string): string[] {
|
|
|
49
49
|
return spec.workers.filter((w) => w.depends_on.includes(nodeId)).map((w) => w.id);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function
|
|
53
|
-
const worktrees = workers.filter((w) => w.worktree);
|
|
52
|
+
export function findRepoOutputOwnershipConflicts(workers: WorkerSpec[]): string[] {
|
|
54
53
|
const claims = new Map<string, string[]>();
|
|
55
|
-
for (const w of
|
|
54
|
+
for (const w of workers) {
|
|
56
55
|
for (const o of w.outputs) {
|
|
57
56
|
if (!o.path.startsWith("output/")) {
|
|
58
57
|
const list = claims.get(o.path) ?? [];
|
|
@@ -71,7 +70,7 @@ function findWorktreeOwnershipConflicts(workers: WorkerSpec[]): string[] {
|
|
|
71
70
|
const aBeforeB = byId.get(a)!.depends_on.includes(b);
|
|
72
71
|
const bBeforeA = byId.get(b)!.depends_on.includes(a);
|
|
73
72
|
if (!aBeforeB && !bBeforeA) {
|
|
74
|
-
errors.push(`
|
|
73
|
+
errors.push(`repo output ownership conflict: "${path}" claimed by "${a}" and "${b}" without ordered handoff`);
|
|
75
74
|
}
|
|
76
75
|
}
|
|
77
76
|
}
|
|
@@ -132,6 +131,21 @@ export function validateFleetSpec(
|
|
|
132
131
|
errors.push(`worker "${id}": output path must be relative and stay within the repo`);
|
|
133
132
|
}
|
|
134
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
|
+
}
|
|
135
149
|
}
|
|
136
150
|
const wEffort = typeof w.effort === "string" ? w.effort as ThinkingLevelName : undefined;
|
|
137
151
|
if (wEffort !== undefined && !THINKING_LEVELS.includes(wEffort)) {
|
|
@@ -144,7 +158,18 @@ export function validateFleetSpec(
|
|
|
144
158
|
model: typeof w.model === "string" ? w.model : undefined,
|
|
145
159
|
effort: wEffort,
|
|
146
160
|
depends_on: Array.isArray(w.depends_on) ? (w.depends_on as string[]) : [],
|
|
147
|
-
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
|
+
}),
|
|
148
173
|
iterate: w.iterate !== false,
|
|
149
174
|
worktree: w.worktree === true,
|
|
150
175
|
});
|
|
@@ -170,8 +195,8 @@ export function validateFleetSpec(
|
|
|
170
195
|
if (!hasIntegratorPath(workers)) {
|
|
171
196
|
errors.push("multiple worktree workers require an integrator that depends on all worktree workers");
|
|
172
197
|
}
|
|
173
|
-
errors.push(...findWorktreeOwnershipConflicts(workers));
|
|
174
198
|
}
|
|
199
|
+
errors.push(...findRepoOutputOwnershipConflicts(workers));
|
|
175
200
|
|
|
176
201
|
let loopConfig: LoopConfig | undefined;
|
|
177
202
|
if (cfg.loop !== undefined && cfg.loop !== null) {
|
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,
|
package/src/model-resolution.ts
CHANGED
|
@@ -16,6 +16,32 @@ export function aliasesFor(model: Model<Api>): string[] {
|
|
|
16
16
|
];
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function canonicalModelRef(model: Model<Api>): string {
|
|
20
|
+
return `${model.provider}/${model.id}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function listModelRefs(registry: ModelRegistryLike, limit = 40): string[] {
|
|
24
|
+
const models = registry.getAvailable().length > 0 ? registry.getAvailable() : registry.getAll();
|
|
25
|
+
return [...new Map(models.map((m) => [canonicalModelRef(m), m])).keys()].slice(0, limit);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function suggestModelRefs(registry: ModelRegistryLike, ref: string, limit = 8): string[] {
|
|
29
|
+
const needle = ref.toLowerCase();
|
|
30
|
+
return listModelRefs(registry, 200)
|
|
31
|
+
.filter((r) => r.toLowerCase().includes(needle) || needle.split(/[/-]/).some((p) => p.length > 2 && r.toLowerCase().includes(p)))
|
|
32
|
+
.slice(0, limit);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatModelError(registry: ModelRegistryLike, label: string, ref: string, error: string): string {
|
|
36
|
+
const suggestions = suggestModelRefs(registry, ref);
|
|
37
|
+
const available = listModelRefs(registry);
|
|
38
|
+
return [
|
|
39
|
+
`${label}: ${error}`,
|
|
40
|
+
suggestions.length > 0 ? `suggestions: ${suggestions.join(", ")}` : undefined,
|
|
41
|
+
`available models: ${available.join(", ")}`,
|
|
42
|
+
].filter(Boolean).join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
19
45
|
export function resolveModelReference(
|
|
20
46
|
registry: ModelRegistryLike,
|
|
21
47
|
ref: string,
|
|
@@ -36,7 +62,6 @@ export function resolveModelReference(
|
|
|
36
62
|
tiers.push(pool.filter((m) => m.id.toLowerCase() === needle));
|
|
37
63
|
tiers.push(pool.filter((m) => byAlias(m, (a) => a === needle)));
|
|
38
64
|
tiers.push(pool.filter((m) => byAlias(m, (a) => a.startsWith(needle))));
|
|
39
|
-
tiers.push(pool.filter((m) => byAlias(m, (a) => a.includes(needle))));
|
|
40
65
|
|
|
41
66
|
for (const tier of tiers) {
|
|
42
67
|
const unique = [...new Map(tier.map((m) => [`${m.provider}/${m.id}`, m])).values()];
|
|
@@ -67,7 +92,7 @@ export function validateFleetModels(
|
|
|
67
92
|
if (seen.has(key)) continue;
|
|
68
93
|
seen.add(key);
|
|
69
94
|
const r = resolveModelReference(registry, ref);
|
|
70
|
-
if (!r.ok) errors.push(
|
|
95
|
+
if (!r.ok) errors.push(formatModelError(registry, label, ref, r.error));
|
|
71
96
|
}
|
|
72
97
|
return errors.length > 0 ? { ok: false, errors } : { ok: true };
|
|
73
98
|
}
|
package/src/preferences.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { canonicalModelRef, formatModelError, resolveModelReference } from "./model-resolution.js";
|
|
5
|
+
import type { ModelRegistryLike } from "./model-resolution.js";
|
|
4
6
|
import { THINKING_LEVELS } from "./types.js";
|
|
5
7
|
import type { ThinkingLevelName } from "./types.js";
|
|
6
8
|
|
|
@@ -59,6 +61,7 @@ export function mergeFleetConfig(raw: unknown, prefs: FleetPreferences): unknown
|
|
|
59
61
|
export function validatePreferenceValue(
|
|
60
62
|
key: string,
|
|
61
63
|
value: string,
|
|
64
|
+
registry?: ModelRegistryLike,
|
|
62
65
|
): { ok: true; parsed: number | string } | { ok: false; error: string } {
|
|
63
66
|
switch (key) {
|
|
64
67
|
case "max_concurrent": {
|
|
@@ -79,6 +82,11 @@ export function validatePreferenceValue(
|
|
|
79
82
|
}
|
|
80
83
|
case "model": {
|
|
81
84
|
if (value.trim().length === 0) return { ok: false, error: "model must be non-empty" };
|
|
85
|
+
if (registry) {
|
|
86
|
+
const r = resolveModelReference(registry, value.trim());
|
|
87
|
+
if (!r.ok) return { ok: false, error: formatModelError(registry, "preference model", value.trim(), r.error) };
|
|
88
|
+
return { ok: true, parsed: canonicalModelRef(r.model) };
|
|
89
|
+
}
|
|
82
90
|
return { ok: true, parsed: value.trim() };
|
|
83
91
|
}
|
|
84
92
|
default:
|
|
@@ -90,8 +98,9 @@ export function setPreference(
|
|
|
90
98
|
prefs: FleetPreferences,
|
|
91
99
|
key: string,
|
|
92
100
|
value: string,
|
|
101
|
+
registry?: ModelRegistryLike,
|
|
93
102
|
): { ok: true; prefs: FleetPreferences } | { ok: false; error: string } {
|
|
94
|
-
const v = validatePreferenceValue(key, value);
|
|
103
|
+
const v = validatePreferenceValue(key, value, registry);
|
|
95
104
|
if (!v.ok) return v;
|
|
96
105
|
return { ok: true, prefs: { ...prefs, [key]: v.parsed } };
|
|
97
106
|
}
|
package/src/prompts.ts
CHANGED
|
@@ -100,6 +100,16 @@ export function buildWorkerPrompt(opts: {
|
|
|
100
100
|
} else {
|
|
101
101
|
for (const o of worker.outputs) {
|
|
102
102
|
out.push(`- ${o.path} (${o.kind}${o.required ? ", REQUIRED" : ", optional"})`);
|
|
103
|
+
if (o.kind === "json" && o.schema) {
|
|
104
|
+
const bits: string[] = [];
|
|
105
|
+
if (o.schema.required_keys && o.schema.required_keys.length > 0) {
|
|
106
|
+
bits.push(`must be a JSON object containing keys: ${o.schema.required_keys.join(", ")}`);
|
|
107
|
+
}
|
|
108
|
+
if (o.schema.number_keys && o.schema.number_keys.length > 0) {
|
|
109
|
+
bits.push(`these keys must be numbers or arrays of numbers: ${o.schema.number_keys.join(", ")}`);
|
|
110
|
+
}
|
|
111
|
+
if (bits.length > 0) out.push(` - ${bits.join("; ")}`);
|
|
112
|
+
}
|
|
103
113
|
}
|
|
104
114
|
out.push("");
|
|
105
115
|
}
|
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,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, rm } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { verifyOutputs } from "./contracts.js";
|
|
3
|
+
import { contractFailureNote, verifyOutputs } from "./contracts.js";
|
|
4
4
|
import { archiveIteration, initFleetState, patchNode, resetForIteration, snapshotIteration, writeState } from "./state.js";
|
|
5
5
|
import { TERMINAL_NODE_STATUSES } from "./types.js";
|
|
6
6
|
import type { FleetSpec, FleetState, IterationSnapshot, NodeState, Verdict, WorkerSpec } from "./types.js";
|
|
@@ -258,7 +258,8 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
258
258
|
tokens: res.tokens,
|
|
259
259
|
cost_usd_estimate: res.cost ?? 0,
|
|
260
260
|
contract_result: contract,
|
|
261
|
-
produced_outputs: contract.checks.filter((c) => c.ok).map((c) => c.path),
|
|
261
|
+
produced_outputs: contract.checks.filter((c) => c.ok || c.actualPath).map((c) => c.path),
|
|
262
|
+
status_note: contract.ok ? undefined : contractFailureNote(contract.checks),
|
|
262
263
|
});
|
|
263
264
|
if (contract.ok) {
|
|
264
265
|
const note = await opts.onNodeCompleted?.(w.id);
|