pi-agent-fleet 0.5.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/package.json +1 -1
- package/src/canvas-client.tsx +18 -51
- package/src/canvas-layout.ts +83 -0
- package/src/canvas.ts +8 -4
- package/src/command.ts +7 -35
- package/src/contracts.ts +9 -5
- package/src/controller.ts +47 -3
- package/src/edits.ts +3 -3
- package/src/fleet-recovery.ts +12 -2
- package/src/prompts.ts +11 -0
- package/src/scheduler.ts +49 -4
- package/src/state.ts +17 -17
- package/src/tools.ts +10 -28
package/package.json
CHANGED
package/src/canvas-client.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
useReactFlow,
|
|
17
17
|
} from "@xyflow/react";
|
|
18
18
|
import type { Edge, Node, NodeProps } from "@xyflow/react";
|
|
19
|
+
import { computePositions, excerptText, NODE_W } from "./canvas-layout.js";
|
|
19
20
|
|
|
20
21
|
/* ---------- payload types (mirror canvas.ts) ---------- */
|
|
21
22
|
interface CanvasNodeView {
|
|
@@ -76,7 +77,6 @@ type TimelineEvent =
|
|
|
76
77
|
interface SessionResp { entries: SessionEntry[]; actions: ActionView[]; events: TimelineEvent[]; task?: string }
|
|
77
78
|
|
|
78
79
|
/* ---------- helpers ---------- */
|
|
79
|
-
const NODE_W = 284;
|
|
80
80
|
function statusClass(s: string): string {
|
|
81
81
|
return "st-" + s.replace(/\s+/g, "_");
|
|
82
82
|
}
|
|
@@ -97,47 +97,6 @@ function j<T>(u: string): Promise<T> {
|
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
/* topo-layer layout, ported from the legacy canvas */
|
|
101
|
-
function topoLayers(nodes: CanvasNodeView[], edges: Array<{ from: string; to: string }>): string[][] {
|
|
102
|
-
const ids = nodes.map((n) => n.id);
|
|
103
|
-
const indeg: Record<string, number> = {};
|
|
104
|
-
const rev: Record<string, string[]> = {};
|
|
105
|
-
ids.forEach((i) => { indeg[i] = 0; rev[i] = []; });
|
|
106
|
-
edges.forEach((e) => { if (e.from in indeg) { indeg[e.to]++; rev[e.from].push(e.to); } });
|
|
107
|
-
const layers: string[][] = [];
|
|
108
|
-
let cur = ids.filter((i) => indeg[i] === 0);
|
|
109
|
-
const seen: Record<string, boolean> = {};
|
|
110
|
-
while (cur.length) {
|
|
111
|
-
layers.push(cur);
|
|
112
|
-
cur.forEach((i) => { seen[i] = true; });
|
|
113
|
-
const next: string[] = [];
|
|
114
|
-
cur.forEach((i) => rev[i].forEach((m) => { if (--indeg[m] === 0) next.push(m); }));
|
|
115
|
-
cur = next;
|
|
116
|
-
}
|
|
117
|
-
ids.forEach((i) => { if (!seen[i]) { layers.push([i]); seen[i] = true; } });
|
|
118
|
-
return layers;
|
|
119
|
-
}
|
|
120
|
-
function median(arr: number[]): number {
|
|
121
|
-
const s = arr.slice().sort((a, b) => a - b);
|
|
122
|
-
const m = Math.floor(s.length / 2);
|
|
123
|
-
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
124
|
-
}
|
|
125
|
-
function reduceCrossings(layers: string[][], edges: Array<{ from: string; to: string }>): string[][] {
|
|
126
|
-
for (let li = 1; li < layers.length; li++) {
|
|
127
|
-
const prevPos: Record<string, number> = {};
|
|
128
|
-
layers[li - 1].forEach((id, i) => { prevPos[id] = i; });
|
|
129
|
-
const key = (id: string) => median(edges.filter((e) => e.to === id).map((e) => prevPos[e.from] ?? 0));
|
|
130
|
-
layers[li].sort((a, b) => key(a) - key(b));
|
|
131
|
-
}
|
|
132
|
-
return layers;
|
|
133
|
-
}
|
|
134
|
-
function computePositions(p: CanvasPayload): Record<string, { x: number; y: number }> {
|
|
135
|
-
const layers = reduceCrossings(topoLayers(p.nodes, p.edges), p.edges);
|
|
136
|
-
const pos: Record<string, { x: number; y: number }> = {};
|
|
137
|
-
layers.forEach((layer, li) => layer.forEach((id, ni) => { pos[id] = { x: li * 360, y: ni * 170 }; }));
|
|
138
|
-
return pos;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
100
|
/* ---------- custom node ---------- */
|
|
142
101
|
type FleetNodeData = {
|
|
143
102
|
view: CanvasNodeView;
|
|
@@ -179,8 +138,8 @@ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
|
|
|
179
138
|
onClick={activate}
|
|
180
139
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activate(); } }}
|
|
181
140
|
>
|
|
182
|
-
<Handle type="target" position={Position.
|
|
183
|
-
<Handle type="target" position={Position.
|
|
141
|
+
<Handle type="target" position={Position.Top} />
|
|
142
|
+
<Handle type="target" position={Position.Top} id="loopIn" />
|
|
184
143
|
<div className="card-body">
|
|
185
144
|
<div className="node-header">
|
|
186
145
|
<span className={"node-dot" + (running ? " pulse" : "")} style={{ background: minimapColor(n.status) }} aria-hidden="true" />
|
|
@@ -210,10 +169,10 @@ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
|
|
|
210
169
|
))}
|
|
211
170
|
</div>
|
|
212
171
|
)}
|
|
213
|
-
{n.status_note && <div className="note">{n.status_note}</div>}
|
|
214
|
-
{failReason && <div className="fail-reason">{failReason}</div>}
|
|
172
|
+
{n.status_note && <div className="note nodrag">{n.status_note}</div>}
|
|
173
|
+
{failReason && <div className="fail-reason nodrag">{failReason}</div>}
|
|
215
174
|
</div>
|
|
216
|
-
<Handle type="source" position={Position.
|
|
175
|
+
<Handle type="source" position={Position.Bottom} />
|
|
217
176
|
<Handle type="source" position={Position.Bottom} id="loop" />
|
|
218
177
|
</div>
|
|
219
178
|
);
|
|
@@ -222,7 +181,7 @@ const nodeTypes = { fleet: FleetNode };
|
|
|
222
181
|
|
|
223
182
|
/* ---------- side panel ---------- */
|
|
224
183
|
function CollapsiblePrompt({ title, text }: { title: string; text: string }) {
|
|
225
|
-
const [open, setOpen] = useState(
|
|
184
|
+
const [open, setOpen] = useState(false);
|
|
226
185
|
if (!text) return null;
|
|
227
186
|
return (
|
|
228
187
|
<div className={"collapsible" + (open ? "" : " collapsed")}>
|
|
@@ -247,15 +206,23 @@ function formatActionDetail(a: { arguments?: Record<string, unknown> }): string
|
|
|
247
206
|
|
|
248
207
|
function TimelineItem({ event }: { event: TimelineEvent }) {
|
|
249
208
|
const [open, setOpen] = useState(event.isError ? true : false);
|
|
209
|
+
const [expanded, setExpanded] = useState(false);
|
|
250
210
|
const ts = event.timestamp ? new Date(event.timestamp).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "";
|
|
251
211
|
if (event.type === "message") {
|
|
212
|
+
const { excerpt, truncated } = excerptText(event.text, 240);
|
|
213
|
+
const text = truncated && !expanded ? excerpt : event.text;
|
|
252
214
|
return (
|
|
253
215
|
<div className={"timeline-msg" + (event.role === "assistant" ? " assistant" : event.role === "user" ? " user" : "")}>
|
|
254
216
|
<div className="timeline-meta">
|
|
255
217
|
<span className="role">{event.role}</span>
|
|
256
218
|
{ts && <span className="ts">{ts}</span>}
|
|
257
219
|
</div>
|
|
258
|
-
<div className="timeline-text">{
|
|
220
|
+
<div className="timeline-text">{text}</div>
|
|
221
|
+
{truncated && (
|
|
222
|
+
<button className="timeline-more" onClick={() => setExpanded((v) => !v)}>
|
|
223
|
+
{expanded ? "show less" : "show more"}
|
|
224
|
+
</button>
|
|
225
|
+
)}
|
|
259
226
|
</div>
|
|
260
227
|
);
|
|
261
228
|
}
|
|
@@ -410,12 +377,12 @@ function SidePanel({ fleet, demo, selected, task, onClose }: { fleet: string | n
|
|
|
410
377
|
)}
|
|
411
378
|
</div>
|
|
412
379
|
<div className="side-body" ref={boxRef} tabIndex={-1}>
|
|
413
|
-
<CollapsiblePrompt title="Instructions" text={resp?.task || task || ""} />
|
|
414
380
|
{resp === null ? (
|
|
415
381
|
<div className="timeline-loading"><span className="spinner" aria-hidden="true" /> Loading session…</div>
|
|
416
382
|
) : (
|
|
417
383
|
<Timeline events={resp.events ?? []} />
|
|
418
384
|
)}
|
|
385
|
+
<CollapsiblePrompt title="Instructions (task prompt)" text={resp?.task || task || ""} />
|
|
419
386
|
</div>
|
|
420
387
|
</div>
|
|
421
388
|
);
|
|
@@ -602,7 +569,7 @@ function Flow() {
|
|
|
602
569
|
// so drag positions and measured sizes (needed by the minimap) survive polling.
|
|
603
570
|
useEffect(() => {
|
|
604
571
|
if (!payload) { setNodes([]); return; }
|
|
605
|
-
const pos = computePositions(payload);
|
|
572
|
+
const pos = computePositions(payload.nodes, payload.edges);
|
|
606
573
|
const gateId = payload.loop
|
|
607
574
|
? (payload.nodes.find((n) => n.id === payload.loop!.gate) ?? payload.nodes.find((n) => n.type === payload.loop!.gate))?.id
|
|
608
575
|
: undefined;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const NODE_W = 284;
|
|
2
|
+
export const NODE_H_GAP = 200;
|
|
3
|
+
export const NODE_W_GAP = 40;
|
|
4
|
+
|
|
5
|
+
export function excerptText(text: string, max: number): { excerpt: string; truncated: boolean } {
|
|
6
|
+
const truncated = text.length > max;
|
|
7
|
+
return { excerpt: truncated ? text.slice(0, max) + "…" : text, truncated };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function topoLayers(ids: string[], edges: Array<{ from: string; to: string }>): string[][] {
|
|
11
|
+
const indeg: Record<string, number> = {};
|
|
12
|
+
const nextById: Record<string, string[]> = {};
|
|
13
|
+
for (const id of ids) {
|
|
14
|
+
indeg[id] = 0;
|
|
15
|
+
nextById[id] = [];
|
|
16
|
+
}
|
|
17
|
+
for (const edge of edges) {
|
|
18
|
+
if (!(edge.from in indeg) || !(edge.to in indeg)) continue;
|
|
19
|
+
indeg[edge.to]++;
|
|
20
|
+
nextById[edge.from].push(edge.to);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const layers: string[][] = [];
|
|
24
|
+
const seen = new Set<string>();
|
|
25
|
+
let cur = ids.filter((id) => indeg[id] === 0);
|
|
26
|
+
while (cur.length) {
|
|
27
|
+
layers.push(cur);
|
|
28
|
+
const next: string[] = [];
|
|
29
|
+
for (const id of cur) {
|
|
30
|
+
seen.add(id);
|
|
31
|
+
for (const to of nextById[id]) {
|
|
32
|
+
indeg[to]--;
|
|
33
|
+
if (indeg[to] === 0) next.push(to);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
cur = next;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const id of ids) {
|
|
40
|
+
if (seen.has(id)) continue;
|
|
41
|
+
layers.push([id]);
|
|
42
|
+
seen.add(id);
|
|
43
|
+
}
|
|
44
|
+
return layers;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function median(values: number[]): number {
|
|
48
|
+
if (!values.length) return 0;
|
|
49
|
+
const sorted = values.slice().sort((a, b) => a - b);
|
|
50
|
+
const mid = Math.floor(sorted.length / 2);
|
|
51
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function reduceCrossings(layers: string[][], edges: Array<{ from: string; to: string }>): string[][] {
|
|
55
|
+
const ordered = layers.map((layer) => layer.slice());
|
|
56
|
+
for (let li = 1; li < ordered.length; li++) {
|
|
57
|
+
const prevPos: Record<string, number> = {};
|
|
58
|
+
ordered[li - 1].forEach((id, i) => {
|
|
59
|
+
prevPos[id] = i;
|
|
60
|
+
});
|
|
61
|
+
const key = (id: string) => median(edges.filter((edge) => edge.to === id).map((edge) => prevPos[edge.from] ?? 0));
|
|
62
|
+
ordered[li].sort((a, b) => key(a) - key(b) || a.localeCompare(b));
|
|
63
|
+
}
|
|
64
|
+
return ordered;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function computePositions(
|
|
68
|
+
nodes: Array<{ id: string }>,
|
|
69
|
+
edges: Array<{ from: string; to: string }>,
|
|
70
|
+
): Record<string, { x: number; y: number }> {
|
|
71
|
+
const layers = reduceCrossings(topoLayers(nodes.map((node) => node.id), edges), edges);
|
|
72
|
+
const maxLayerLen = layers.reduce((max, layer) => Math.max(max, layer.length), 0);
|
|
73
|
+
const stepX = NODE_W + NODE_W_GAP;
|
|
74
|
+
const pos: Record<string, { x: number; y: number }> = {};
|
|
75
|
+
|
|
76
|
+
layers.forEach((layer, li) => {
|
|
77
|
+
const xOffset = ((maxLayerLen - layer.length) * stepX) / 2;
|
|
78
|
+
layer.forEach((id, ni) => {
|
|
79
|
+
pos[id] = { x: xOffset + ni * stepX, y: li * NODE_H_GAP };
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
return pos;
|
|
83
|
+
}
|
package/src/canvas.ts
CHANGED
|
@@ -628,7 +628,7 @@ async function buildClientBundle(): Promise<string> {
|
|
|
628
628
|
platform: "browser",
|
|
629
629
|
target: "es2020",
|
|
630
630
|
jsx: "automatic",
|
|
631
|
-
minify:
|
|
631
|
+
minify: false,
|
|
632
632
|
write: false,
|
|
633
633
|
logLevel: "silent",
|
|
634
634
|
define: { "process.env.NODE_ENV": '"production"' },
|
|
@@ -726,6 +726,7 @@ main { display:flex; flex:1 1 auto; min-height:0; }
|
|
|
726
726
|
.flags { margin-top:6px; font-size:11px; color:var(--warn); }
|
|
727
727
|
.flags span { cursor:help; }
|
|
728
728
|
.note { margin-top:6px; font-size:13px; color:var(--warn); }
|
|
729
|
+
.note, .fail-reason { user-select:text; cursor:text; }
|
|
729
730
|
#side { width:420px; flex:0 0 auto; border-left:1px solid var(--line); overflow:hidden; display:flex; flex-direction:column; background:var(--bg); }
|
|
730
731
|
#side:focus, #side:focus-visible { outline:none; }
|
|
731
732
|
.side-head { display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; flex-shrink:0; gap:6px; padding:12px 12px 8px; border-bottom:1px solid var(--line); background:var(--bg); }
|
|
@@ -756,19 +757,21 @@ main { display:flex; flex:1 1 auto; min-height:0; }
|
|
|
756
757
|
.timeline-row .action-detail { flex:1; min-width:0; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }
|
|
757
758
|
.timeline-row .action-error { color:var(--bad); }
|
|
758
759
|
.timeline-row .ts { flex-shrink:0; color:var(--wire); font-size:11px; font-variant-numeric:tabular-nums; }
|
|
759
|
-
.timeline-msg { padding:8px; margin-bottom:6px; border-radius:5px; background:var(--panel); font-size:13px; }
|
|
760
|
+
.timeline-msg { padding:4px 8px; margin-bottom:6px; border-radius:5px; background:var(--panel); font-size:13px; }
|
|
760
761
|
.timeline-msg.user { border-left:1px solid var(--accent); background:color-mix(in srgb, var(--accent) 5%, var(--panel)); }
|
|
761
762
|
.timeline-msg.assistant { border-left:1px solid var(--ok); background:color-mix(in srgb, var(--ok) 5%, var(--panel)); }
|
|
762
763
|
.timeline-msg .role { font-weight:700; margin-bottom:4px; }
|
|
763
764
|
.timeline-msg .timeline-meta { display:flex; justify-content:space-between; align-items:center; margin-bottom:3px; }
|
|
764
765
|
.timeline-msg .timeline-meta .ts { color:var(--wire); font-size:11px; }
|
|
765
|
-
.timeline-msg .timeline-text { white-space:pre-wrap; word-break:break-word; }
|
|
766
|
+
.timeline-msg .timeline-text { font-size:12px; line-height:1.45; white-space:pre-wrap; word-break:break-word; user-select:text; }
|
|
766
767
|
.timeline-loading { display:flex; align-items:center; justify-content:center; gap:8px; padding:16px 8px; color:var(--muted); font-size:13px; }
|
|
767
768
|
.timeline-empty { padding:16px 8px; color:var(--muted); font-size:13px; text-align:center; }
|
|
768
769
|
.activity-toggle { flex-shrink:0; width:18px; height:18px; padding:0; background:transparent; border:1px solid var(--line); border-radius:4px; color:var(--muted); cursor:pointer; font-size:11px; line-height:1; }
|
|
769
770
|
.activity-toggle:hover { background:var(--bg); color:var(--fg); }
|
|
770
|
-
.activity-body { padding:8px; border-top:1px solid var(--line); font-size:12px; color:var(--muted); font-family:var(--mono); white-space:pre-wrap; word-break:break-word; }
|
|
771
|
+
.activity-body { padding:8px; border-top:1px solid var(--line); font-size:12px; color:var(--muted); font-family:var(--mono); white-space:pre-wrap; word-break:break-word; user-select:text; }
|
|
771
772
|
.activity-body pre { margin:0; background:var(--bg); padding:8px; border-radius:4px; overflow:auto; font-size:11px; }
|
|
773
|
+
.timeline-more { margin-top:4px; padding:0; min-height:auto; border:none; background:none; color:var(--accent); font-size:11px; }
|
|
774
|
+
.timeline-more:hover { text-decoration:underline; }
|
|
772
775
|
.react-flow__minimap-node.st-completed { fill:var(--ok); }
|
|
773
776
|
.react-flow__minimap-node.st-running { fill:var(--accent); }
|
|
774
777
|
.react-flow__minimap-node.st-failed, .react-flow__minimap-node.st-contract_failed { fill:var(--bad); }
|
|
@@ -812,6 +815,7 @@ button.toggled { border-color:var(--accent); color:var(--accent); }
|
|
|
812
815
|
.empty-cta:hover { background:var(--panel); }
|
|
813
816
|
/* failure reason surfaced on failed cards */
|
|
814
817
|
.fail-reason { margin-top:6px; font-size:13px; color:var(--bad); }
|
|
818
|
+
.action-detail { user-select:text; }
|
|
815
819
|
/* respect reduced-motion: keep the state, drop the perpetual movement */
|
|
816
820
|
@media (prefers-reduced-motion: reduce) {
|
|
817
821
|
.spinner { animation:none; border-top-color:var(--accent); opacity:0.6; }
|
package/src/command.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { activeFleet, currentState, ensureCanvas, killFleet,
|
|
3
|
-
import {
|
|
2
|
+
import { activeFleet, currentState, ensureCanvas, killFleet, requestRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
|
|
3
|
+
import { writeWorkerPrompts } from "./fleet-store.js";
|
|
4
4
|
import { openInBrowser, listFleetRoots } from "./canvas.js";
|
|
5
5
|
import { insertWorkers } from "./insert.js";
|
|
6
6
|
import { editConfig, editNode, type ConfigEditKey, type NodeEditKey } from "./edits.js";
|
|
7
|
-
import { listModelRefs
|
|
7
|
+
import { listModelRefs } from "./model-resolution.js";
|
|
8
8
|
import { clearPreference, loadPreferences, PREFERENCE_KEYS, savePreferences, setPreference } from "./preferences.js";
|
|
9
9
|
import { recoverLatestFleet } from "./fleet-recovery.js";
|
|
10
|
-
import {
|
|
10
|
+
import { writeState } from "./state.js";
|
|
11
11
|
import { buildWidgetLines } from "./ui.js";
|
|
12
12
|
import { renderDag } from "./viz.js";
|
|
13
13
|
|
|
@@ -187,43 +187,15 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
|
|
|
187
187
|
ctx.ui.notify("usage: /fleet relaunch <node_id> [model]", "warning");
|
|
188
188
|
return;
|
|
189
189
|
}
|
|
190
|
-
if (active.running) {
|
|
191
|
-
ctx.ui.notify("fleet is running", "warning");
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
190
|
await currentState(active);
|
|
195
191
|
if (active.state.status === "completed") {
|
|
196
192
|
ctx.ui.notify("fleet completed, nothing to relaunch", "warning");
|
|
197
193
|
return;
|
|
198
194
|
}
|
|
199
|
-
const worker = active.spec.workers.find((w) => w.id === target);
|
|
200
|
-
if (!worker) {
|
|
201
|
-
ctx.ui.notify(`unknown node "${target}"`, "warning");
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
const node = active.state.nodes[target];
|
|
205
|
-
const relaunchable: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed"]);
|
|
206
|
-
if (!node || !relaunchable.has(node.status)) {
|
|
207
|
-
ctx.ui.notify(`node "${target}" status ${node?.status ?? "missing"} cannot be relaunched; must be failed, contract_failed, or killed`, "warning");
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
195
|
const model = args.trim().split(/\s+/).slice(2).join(" ") || undefined;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
ctx.ui.notify(resolved.error, "error");
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
const canonical = `${resolved.model.provider}/${resolved.model.id}`;
|
|
218
|
-
active.spec.workers = active.spec.workers.map((w) => w.id === target ? { ...w, model: canonical } : w);
|
|
219
|
-
await persistFleetJson(active);
|
|
220
|
-
}
|
|
221
|
-
active.state = resetForRelaunch(active.state, active.spec, target);
|
|
222
|
-
await writeState(active.fleetRoot, active.state);
|
|
223
|
-
await writeWorkerPrompts(active);
|
|
224
|
-
prepareRelaunch(active, target);
|
|
225
|
-
void startLoop(active, ctx, false, true);
|
|
226
|
-
ctx.ui.notify(`fleet relaunch requested for ${target}`, "info");
|
|
196
|
+
const result = await requestRelaunch(active, target, model, ctx.modelRegistry);
|
|
197
|
+
if (result.startNow) void startLoop(active, ctx, false, true);
|
|
198
|
+
ctx.ui.notify(result.message, result.startNow ? "info" : result.message.startsWith("relaunch queued") ? "info" : "warning");
|
|
227
199
|
return;
|
|
228
200
|
}
|
|
229
201
|
if (cmd === "add") {
|
package/src/contracts.ts
CHANGED
|
@@ -20,7 +20,7 @@ function firstLines(content: string, n = 5): string {
|
|
|
20
20
|
.join(" / ");
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
async function checkOne(workerDir: string, repoCwd: string, o: ContractOutput): Promise<ContractCheck> {
|
|
23
|
+
async function checkOne(workerDir: string, repoCwd: string, o: ContractOutput, notBeforeMs?: number): Promise<ContractCheck> {
|
|
24
24
|
const full = resolvePath(workerDir, repoCwd, o.path);
|
|
25
25
|
const base: Omit<ContractCheck, "ok" | "error"> = { path: o.path, kind: o.kind, required: o.required, actualPath: full };
|
|
26
26
|
const fail = (error: string): ContractCheck => ({ ...base, ok: false, error });
|
|
@@ -28,9 +28,12 @@ async function checkOne(workerDir: string, repoCwd: string, o: ContractOutput):
|
|
|
28
28
|
try {
|
|
29
29
|
const s = await stat(full);
|
|
30
30
|
if (o.kind === "file-exists") {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
if (s.size === 0) return fail("empty file");
|
|
32
|
+
const repoRelative = !isAbsolute(o.path) && !o.path.startsWith("output/");
|
|
33
|
+
if (repoRelative && notBeforeMs !== undefined && s.mtimeMs < notBeforeMs) {
|
|
34
|
+
return fail("pre-existing repo file not modified since worker start");
|
|
35
|
+
}
|
|
36
|
+
return { ...base, ok: true };
|
|
34
37
|
}
|
|
35
38
|
content = await readFile(full, "utf-8");
|
|
36
39
|
} catch {
|
|
@@ -99,8 +102,9 @@ export async function verifyOutputs(opts: {
|
|
|
99
102
|
workerDir: string;
|
|
100
103
|
repoCwd: string;
|
|
101
104
|
outputs: ContractOutput[];
|
|
105
|
+
notBeforeMs?: number;
|
|
102
106
|
}): Promise<ContractResult> {
|
|
103
|
-
const checks = await Promise.all(opts.outputs.map((o) => checkOne(opts.workerDir, opts.repoCwd, o)));
|
|
107
|
+
const checks = await Promise.all(opts.outputs.map((o) => checkOne(opts.workerDir, opts.repoCwd, o, opts.notBeforeMs)));
|
|
104
108
|
const ok = checks.every((c) => !c.required || c.ok);
|
|
105
109
|
|
|
106
110
|
const verdictCheck = checks.find((c) => c.kind === "verdict" && c.ok);
|
package/src/controller.ts
CHANGED
|
@@ -2,14 +2,14 @@ import { readFile, writeFile } from "node:fs/promises";
|
|
|
2
2
|
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
|
-
import { writeWorkerPrompts } from "./fleet-store.js";
|
|
5
|
+
import { persistFleetJson, writeWorkerPrompts } from "./fleet-store.js";
|
|
6
6
|
import { recoverLatestFleet } from "./fleet-recovery.js";
|
|
7
7
|
import { resolveModelReference, type ModelRegistryLike } from "./model-resolution.js";
|
|
8
8
|
import { insertWorkers } from "./insert.js";
|
|
9
9
|
import { writeReport } from "./report.js";
|
|
10
10
|
import { runWorker, sessionFactoryForModel, workerWithResolvedModel, type AgentSessionLike } from "./runner.js";
|
|
11
11
|
import { runFleet } from "./scheduler.js";
|
|
12
|
-
import { patchNode, readState, writeState } from "./state.js";
|
|
12
|
+
import { patchNode, readState, resetForRelaunch, writeState } from "./state.js";
|
|
13
13
|
import type { FleetSpec, FleetState } from "./types.js";
|
|
14
14
|
import { TERMINAL_NODE_STATUSES } from "./types.js";
|
|
15
15
|
import { buildWidgetLines } from "./ui.js";
|
|
@@ -26,6 +26,7 @@ export interface ActiveFleet {
|
|
|
26
26
|
costWarned?: boolean;
|
|
27
27
|
sessions: Map<string, AgentSessionLike>;
|
|
28
28
|
killedNodes: Set<string>;
|
|
29
|
+
relaunchRequests: Set<string>;
|
|
29
30
|
widgetVisible?: boolean;
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -89,6 +90,48 @@ export function prepareRelaunch(fleet: ActiveFleet, nodeId: string): void {
|
|
|
89
90
|
fleet.pauseSwitch.paused = false;
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
export interface RelaunchRequestResult {
|
|
94
|
+
message: string;
|
|
95
|
+
startNow: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function requestRelaunch(
|
|
99
|
+
fleet: ActiveFleet,
|
|
100
|
+
nodeId: string,
|
|
101
|
+
model: string | undefined,
|
|
102
|
+
registry: ModelRegistryLike,
|
|
103
|
+
): Promise<RelaunchRequestResult> {
|
|
104
|
+
const worker = fleet.spec.workers.find((w) => w.id === nodeId);
|
|
105
|
+
if (!worker) return { message: `unknown node "${nodeId}"`, startNow: false };
|
|
106
|
+
const node = fleet.state.nodes[nodeId];
|
|
107
|
+
const relaunchable: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed"]);
|
|
108
|
+
if (!node || !relaunchable.has(node.status)) {
|
|
109
|
+
return {
|
|
110
|
+
message: `node "${nodeId}" status ${node?.status ?? "missing"} cannot be relaunched; must be failed, contract_failed, or killed`,
|
|
111
|
+
startNow: false,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (model) {
|
|
115
|
+
const resolved = resolveModelReference(registry, model);
|
|
116
|
+
if (!resolved.ok) return { message: resolved.error, startNow: false };
|
|
117
|
+
const canonical = `${resolved.model.provider}/${resolved.model.id}`;
|
|
118
|
+
fleet.spec.workers = fleet.spec.workers.map((w) => (w.id === nodeId ? { ...w, model: canonical } : w));
|
|
119
|
+
await persistFleetJson(fleet);
|
|
120
|
+
}
|
|
121
|
+
prepareRelaunch(fleet, nodeId);
|
|
122
|
+
if (fleet.running) {
|
|
123
|
+
fleet.relaunchRequests.add(nodeId);
|
|
124
|
+
return {
|
|
125
|
+
message: `relaunch queued for ${nodeId} (fleet running; dispatches on next scheduler pass)`,
|
|
126
|
+
startNow: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
fleet.state = resetForRelaunch(fleet.state, fleet.spec, nodeId);
|
|
130
|
+
await writeState(fleet.fleetRoot, fleet.state);
|
|
131
|
+
await writeWorkerPrompts(fleet);
|
|
132
|
+
return { message: `fleet relaunch requested for ${nodeId}`, startNow: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
92
135
|
export function registerNodeSession(fleet: ActiveFleet, nodeId: string, session: AgentSessionLike): void {
|
|
93
136
|
fleet.sessions.set(nodeId, session);
|
|
94
137
|
if (fleet.killedNodes.has(nodeId)) void session.abort().catch(() => {});
|
|
@@ -223,6 +266,7 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
223
266
|
killSwitch: fleet.killSwitch,
|
|
224
267
|
pauseSwitch: fleet.pauseSwitch,
|
|
225
268
|
nodeKills: fleet.killedNodes,
|
|
269
|
+
relaunchRequests: fleet.relaunchRequests,
|
|
226
270
|
onNodeChange: (nodeId, nodeState) => {
|
|
227
271
|
fleet.state = fleet.state.nodes[nodeId]
|
|
228
272
|
? patchNode(fleet.fleetRoot, fleet.state, nodeId, nodeState)
|
|
@@ -294,7 +338,7 @@ export async function killFleet(target: string, cwd?: string): Promise<string> {
|
|
|
294
338
|
const worker = active.spec.workers.find((w) => w.id === target);
|
|
295
339
|
const node = active.state.nodes[target];
|
|
296
340
|
if (!worker || !node) return `unknown node "${target}"`;
|
|
297
|
-
if (TERMINAL_NODE_STATUSES.has(node.status)) return `node "${target}" already ${node.status}`;
|
|
341
|
+
if (TERMINAL_NODE_STATUSES.has(node.status) && node.status !== "blocked") return `node "${target}" already ${node.status}`;
|
|
298
342
|
active.killedNodes.add(target);
|
|
299
343
|
const session = active.sessions.get(target);
|
|
300
344
|
if (session) {
|
package/src/edits.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface EditResult {
|
|
|
15
15
|
message: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const EDITABLE_NODE_STATUSES: ReadonlySet<NodeStatus> = new Set(["pending", "ready", "failed", "contract_failed", "killed"]);
|
|
18
|
+
const EDITABLE_NODE_STATUSES: ReadonlySet<NodeStatus> = new Set(["pending", "ready", "failed", "contract_failed", "killed", "blocked"]);
|
|
19
19
|
|
|
20
20
|
export async function editNode(
|
|
21
21
|
fleet: ActiveFleet,
|
|
@@ -28,8 +28,8 @@ export async function editNode(
|
|
|
28
28
|
const node = fleet.state.nodes[nodeId];
|
|
29
29
|
if (!worker || !node) return { ok: false, message: `unknown node "${nodeId}"` };
|
|
30
30
|
if (!EDITABLE_NODE_STATUSES.has(node.status)) {
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
return { ok: false, message: `node "${nodeId}" is ${node.status}; only pending, blocked, failed, contract_failed, or killed nodes can be edited` };
|
|
32
|
+
}
|
|
33
33
|
switch (key) {
|
|
34
34
|
case "model": {
|
|
35
35
|
const r = resolveModelReference(registry, value);
|
package/src/fleet-recovery.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { ActiveFleet } from "./controller.js";
|
|
4
|
-
import { readState } from "./state.js";
|
|
4
|
+
import { initFleetState, readState } from "./state.js";
|
|
5
5
|
import type { FleetSpec, FleetState } from "./types.js";
|
|
6
6
|
|
|
7
7
|
export interface FleetRootInfo {
|
|
@@ -13,7 +13,16 @@ export interface FleetRootInfo {
|
|
|
13
13
|
|
|
14
14
|
export async function readDiskFleet(fleetRoot: string): Promise<ActiveFleet> {
|
|
15
15
|
const spec = JSON.parse(await readFile(join(fleetRoot, "fleet.json"), "utf-8")) as FleetSpec;
|
|
16
|
-
|
|
16
|
+
// Bare fleet.json-only root (e.g. hand-copied spec, never planned/launched):
|
|
17
|
+
// synthesize a fresh pending state so the canvas can still render the DAG.
|
|
18
|
+
let state: FleetState;
|
|
19
|
+
try {
|
|
20
|
+
state = await readState(fleetRoot);
|
|
21
|
+
} catch (e: unknown) {
|
|
22
|
+
const err = e as { code?: string };
|
|
23
|
+
if (err.code !== "ENOENT") throw e;
|
|
24
|
+
state = initFleetState(spec);
|
|
25
|
+
}
|
|
17
26
|
return {
|
|
18
27
|
spec,
|
|
19
28
|
fleetRoot,
|
|
@@ -23,6 +32,7 @@ export async function readDiskFleet(fleetRoot: string): Promise<ActiveFleet> {
|
|
|
23
32
|
running: false,
|
|
24
33
|
sessions: new Map(),
|
|
25
34
|
killedNodes: new Set(),
|
|
35
|
+
relaunchRequests: new Set(),
|
|
26
36
|
};
|
|
27
37
|
}
|
|
28
38
|
|
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, "");
|
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
3
|
import { contractFailureNote, verifyOutputs } from "./contracts.js";
|
|
4
|
-
import { archiveIteration, initFleetState, patchNode, resetForIteration, snapshotIteration, writeState } from "./state.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(),
|
|
@@ -259,7 +298,7 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
259
298
|
cost_usd_estimate: res.cost ?? 0,
|
|
260
299
|
contract_result: contract,
|
|
261
300
|
produced_outputs: contract.checks.filter((c) => c.ok || c.actualPath).map((c) => c.path),
|
|
262
|
-
status_note: contract.ok ?
|
|
301
|
+
status_note: contract.ok ? costNote : [contractFailureNote(contract.checks), costNote].filter(Boolean).join(" · "),
|
|
263
302
|
});
|
|
264
303
|
if (contract.ok) {
|
|
265
304
|
const note = await opts.onNodeCompleted?.(w.id);
|
|
@@ -280,8 +319,14 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
280
319
|
await Promise.allSettled([...running]);
|
|
281
320
|
};
|
|
282
321
|
|
|
322
|
+
const runPassUntilDrained = async () => {
|
|
323
|
+
do {
|
|
324
|
+
await runPass();
|
|
325
|
+
} while (opts.relaunchRequests && opts.relaunchRequests.size > 0);
|
|
326
|
+
};
|
|
327
|
+
|
|
283
328
|
if (!loop) {
|
|
284
|
-
await
|
|
329
|
+
await runPassUntilDrained();
|
|
285
330
|
const anyFailed = spec.workers.some((w) =>
|
|
286
331
|
["failed", "contract_failed"].includes(state.nodes[w.id]?.status ?? ""));
|
|
287
332
|
const finalStatus = opts.killSwitch?.killed ? "killed" : anyFailed ? "failed" : "completed";
|
|
@@ -311,7 +356,7 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
311
356
|
|
|
312
357
|
await opts.prepareIteration?.(n, state);
|
|
313
358
|
|
|
314
|
-
await
|
|
359
|
+
await runPassUntilDrained();
|
|
315
360
|
|
|
316
361
|
let verdict: Verdict | null = null;
|
|
317
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
|
@@ -4,15 +4,15 @@ import { Type } from "typebox";
|
|
|
4
4
|
import { validateFleetSpec } from "./dag.js";
|
|
5
5
|
import { insertWorkers } from "./insert.js";
|
|
6
6
|
import { loadPreferences, mergeFleetConfig } from "./preferences.js";
|
|
7
|
-
import { activeFleet, currentState, dagPreview, ensureCanvas, killFleet,
|
|
7
|
+
import { activeFleet, currentState, dagPreview, ensureCanvas, killFleet, requestRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
|
|
8
8
|
import { openInBrowser, listFleetRoots } from "./canvas.js";
|
|
9
9
|
import { editConfig, editNode, type ConfigEditKey, type NodeEditKey } from "./edits.js";
|
|
10
|
-
import { ensureFleetGitignore, fleetRootFor, isInsideGitRepo,
|
|
10
|
+
import { ensureFleetGitignore, fleetRootFor, isInsideGitRepo, writePlanFiles, writeWorkerPrompts } from "./fleet-store.js";
|
|
11
11
|
import { recoverLatestFleet } from "./fleet-recovery.js";
|
|
12
|
-
import { listModelRefs,
|
|
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> = {}) {
|
|
@@ -88,7 +88,7 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
88
88
|
const state = initFleetState(v.spec);
|
|
89
89
|
await ensureFleetGitignore(ctx.cwd);
|
|
90
90
|
await writePlanFiles(fleetRoot, v.spec, state);
|
|
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(), widgetVisible: false };
|
|
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 };
|
|
92
92
|
updateWidget(ctx, active);
|
|
93
93
|
|
|
94
94
|
const dag = await dagPreview(v.spec, undefined, fleetRoot);
|
|
@@ -242,7 +242,7 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
242
242
|
pi.registerTool({
|
|
243
243
|
name: "fleet_relaunch",
|
|
244
244
|
label: "Fleet Relaunch",
|
|
245
|
-
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.",
|
|
246
246
|
promptSnippet: "Relaunch a failed fleet node.",
|
|
247
247
|
parameters: Type.Object({
|
|
248
248
|
node_id: Type.String({ description: "Worker id to relaunch" }),
|
|
@@ -251,30 +251,12 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
251
251
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
252
252
|
const active = activeFleet.current;
|
|
253
253
|
if (!active) return textResult("no fleet planned yet");
|
|
254
|
-
if (active.running) return textResult("fleet is running");
|
|
255
254
|
const fleet = active;
|
|
256
255
|
await currentState(fleet);
|
|
257
256
|
if (fleet.state.status === "completed") return textResult("fleet completed, nothing to relaunch");
|
|
258
|
-
const
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
const relaunchable: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed"]);
|
|
262
|
-
if (!node || !relaunchable.has(node.status)) {
|
|
263
|
-
return textResult(`node "${params.node_id}" status ${node?.status ?? "missing"} cannot be relaunched; must be failed, contract_failed, or killed`);
|
|
264
|
-
}
|
|
265
|
-
if (params.model) {
|
|
266
|
-
const resolved = resolveModelReference(ctx.modelRegistry, params.model);
|
|
267
|
-
if (!resolved.ok) return textResult(resolved.error);
|
|
268
|
-
const canonical = `${resolved.model.provider}/${resolved.model.id}`;
|
|
269
|
-
fleet.spec.workers = fleet.spec.workers.map((w) => w.id === params.node_id ? { ...w, model: canonical } : w);
|
|
270
|
-
await persistFleetJson(fleet);
|
|
271
|
-
}
|
|
272
|
-
fleet.state = resetForRelaunch(fleet.state, fleet.spec, params.node_id);
|
|
273
|
-
await writeState(fleet.fleetRoot, fleet.state);
|
|
274
|
-
await writeWorkerPrompts(fleet);
|
|
275
|
-
prepareRelaunch(fleet, params.node_id);
|
|
276
|
-
void startLoop(fleet, ctx, false, true);
|
|
277
|
-
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);
|
|
278
260
|
},
|
|
279
261
|
});
|
|
280
262
|
|
|
@@ -353,7 +335,7 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
353
335
|
pi.registerTool({
|
|
354
336
|
name: "fleet_edit",
|
|
355
337
|
label: "Fleet Edit",
|
|
356
|
-
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
|
|
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).",
|
|
357
339
|
promptSnippet: "Edit a pending fleet node or fleet config.",
|
|
358
340
|
parameters: Type.Object({
|
|
359
341
|
node_id: Type.Optional(Type.String({ description: "Worker id to edit; omit for fleet config edits" })),
|