pi-agent-fleet 0.5.0 → 0.7.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 +117 -17
- package/src/dag.ts +5 -1
- package/src/edits.ts +24 -5
- package/src/fleet-recovery.ts +12 -2
- package/src/prompts.ts +11 -0
- package/src/runner.ts +63 -2
- package/src/scheduler.ts +173 -14
- package/src/state.ts +19 -17
- package/src/tools.ts +13 -29
- package/src/types.ts +9 -0
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
|
|
|
@@ -69,7 +70,37 @@ export async function statusText(fleet: ActiveFleet): Promise<string> {
|
|
|
69
70
|
: failed ? `next: fleet_relaunch ${failed[0]}`
|
|
70
71
|
: state.status === "completed" ? `next: read report ${reportPath}`
|
|
71
72
|
: `next: inspect ${join(fleet.fleetRoot, "state.json")}`;
|
|
72
|
-
|
|
73
|
+
let crashWarning = "";
|
|
74
|
+
if (state.status === "running") {
|
|
75
|
+
const heartbeat = state.heartbeat_at;
|
|
76
|
+
const stale = !heartbeat || (Date.now() - new Date(heartbeat).getTime() > 60000);
|
|
77
|
+
if (stale && typeof state.pid === "number") {
|
|
78
|
+
let dead = false;
|
|
79
|
+
try {
|
|
80
|
+
process.kill(state.pid, 0);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if ((e as NodeJS.ErrnoException).code === "ESRCH") dead = true;
|
|
83
|
+
}
|
|
84
|
+
if (dead) {
|
|
85
|
+
crashWarning = `\n\nwarning: fleet appears crashed (stale heartbeat/dead pid ${state.pid}); run fleet_continue to recover`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return `${renderDag(fleet.spec, state)}\n\nreport: ${reportPath}\n${next}${crashWarning}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function checkCostLimits(fleet: ActiveFleet, ctx: ExtensionContext): void {
|
|
93
|
+
const warn = fleet.spec.config.warn_cost_usd;
|
|
94
|
+
const cap = fleet.spec.config.max_cost_usd;
|
|
95
|
+
const cost = fleet.state.cost_usd_estimate;
|
|
96
|
+
if (warn && !fleet.costWarned && cost >= warn) {
|
|
97
|
+
fleet.costWarned = true;
|
|
98
|
+
if (ctx.hasUI) ctx.ui.notify(`fleet cost warning: $${cost.toFixed(4)} >= $${warn}`, "warning");
|
|
99
|
+
}
|
|
100
|
+
if (cap && cost >= cap && !fleet.killSwitch.killed) {
|
|
101
|
+
fleet.killSwitch.killed = true;
|
|
102
|
+
if (ctx.hasUI) ctx.ui.notify(`fleet cost cap reached: $${cost.toFixed(4)} >= $${cap.toFixed(4)}`, "error");
|
|
103
|
+
}
|
|
73
104
|
}
|
|
74
105
|
|
|
75
106
|
export async function dagPreview(spec: FleetSpec, state: FleetState | undefined, fleetRoot: string): Promise<string> {
|
|
@@ -89,6 +120,48 @@ export function prepareRelaunch(fleet: ActiveFleet, nodeId: string): void {
|
|
|
89
120
|
fleet.pauseSwitch.paused = false;
|
|
90
121
|
}
|
|
91
122
|
|
|
123
|
+
export interface RelaunchRequestResult {
|
|
124
|
+
message: string;
|
|
125
|
+
startNow: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function requestRelaunch(
|
|
129
|
+
fleet: ActiveFleet,
|
|
130
|
+
nodeId: string,
|
|
131
|
+
model: string | undefined,
|
|
132
|
+
registry: ModelRegistryLike,
|
|
133
|
+
): Promise<RelaunchRequestResult> {
|
|
134
|
+
const worker = fleet.spec.workers.find((w) => w.id === nodeId);
|
|
135
|
+
if (!worker) return { message: `unknown node "${nodeId}"`, startNow: false };
|
|
136
|
+
const node = fleet.state.nodes[nodeId];
|
|
137
|
+
const relaunchable: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed"]);
|
|
138
|
+
if (!node || !relaunchable.has(node.status)) {
|
|
139
|
+
return {
|
|
140
|
+
message: `node "${nodeId}" status ${node?.status ?? "missing"} cannot be relaunched; must be failed, contract_failed, or killed`,
|
|
141
|
+
startNow: false,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (model) {
|
|
145
|
+
const resolved = resolveModelReference(registry, model);
|
|
146
|
+
if (!resolved.ok) return { message: resolved.error, startNow: false };
|
|
147
|
+
const canonical = `${resolved.model.provider}/${resolved.model.id}`;
|
|
148
|
+
fleet.spec.workers = fleet.spec.workers.map((w) => (w.id === nodeId ? { ...w, model: canonical } : w));
|
|
149
|
+
await persistFleetJson(fleet);
|
|
150
|
+
}
|
|
151
|
+
prepareRelaunch(fleet, nodeId);
|
|
152
|
+
if (fleet.running) {
|
|
153
|
+
fleet.relaunchRequests.add(nodeId);
|
|
154
|
+
return {
|
|
155
|
+
message: `relaunch queued for ${nodeId} (fleet running; dispatches on next scheduler pass)`,
|
|
156
|
+
startNow: false,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
fleet.state = resetForRelaunch(fleet.state, fleet.spec, nodeId);
|
|
160
|
+
await writeState(fleet.fleetRoot, fleet.state);
|
|
161
|
+
await writeWorkerPrompts(fleet);
|
|
162
|
+
return { message: `fleet relaunch requested for ${nodeId}`, startNow: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
92
165
|
export function registerNodeSession(fleet: ActiveFleet, nodeId: string, session: AgentSessionLike): void {
|
|
93
166
|
fleet.sessions.set(nodeId, session);
|
|
94
167
|
if (fleet.killedNodes.has(nodeId)) void session.abort().catch(() => {});
|
|
@@ -137,17 +210,40 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
137
210
|
|
|
138
211
|
fleet.running = true;
|
|
139
212
|
fleet.costWarned = false;
|
|
140
|
-
fleet.state = { ...fleet.state, status: "running" };
|
|
213
|
+
fleet.state = { ...fleet.state, status: "running", pid: process.pid, heartbeat_at: new Date().toISOString() };
|
|
214
|
+
await writeState(fleet.fleetRoot, fleet.state);
|
|
141
215
|
const stopSpinner = startSpinner(ctx, fleet);
|
|
142
216
|
updateWidget(ctx, fleet);
|
|
143
217
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
218
|
+
let lastHeartbeatWrite = Date.now();
|
|
219
|
+
const heartbeatInterval = setInterval(async () => {
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
if (now - lastHeartbeatWrite < 5000) return;
|
|
222
|
+
fleet.state = { ...fleet.state, heartbeat_at: new Date().toISOString() };
|
|
223
|
+
lastHeartbeatWrite = now;
|
|
224
|
+
try {
|
|
225
|
+
await writeState(fleet.fleetRoot, fleet.state);
|
|
226
|
+
} catch {
|
|
227
|
+
// ignore heartbeat write failures
|
|
228
|
+
}
|
|
229
|
+
}, 5000);
|
|
230
|
+
if (typeof heartbeatInterval.unref === "function") heartbeatInterval.unref();
|
|
231
|
+
|
|
232
|
+
let cleanedUp = false;
|
|
233
|
+
let finalStateWritten = false;
|
|
234
|
+
const cleanup = () => {
|
|
235
|
+
if (cleanedUp) return;
|
|
236
|
+
cleanedUp = true;
|
|
237
|
+
stopSpinner();
|
|
238
|
+
clearInterval(heartbeatInterval);
|
|
239
|
+
};
|
|
240
|
+
const writeFinalState = async () => {
|
|
241
|
+
if (finalStateWritten) return;
|
|
242
|
+
finalStateWritten = true;
|
|
243
|
+
try {
|
|
244
|
+
await writeState(fleet.fleetRoot, fleet.state);
|
|
245
|
+
} catch {
|
|
246
|
+
// best-effort final persistence
|
|
151
247
|
}
|
|
152
248
|
};
|
|
153
249
|
|
|
@@ -186,6 +282,7 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
186
282
|
repoCwd: worktreeCwd,
|
|
187
283
|
sessionDir,
|
|
188
284
|
thinkingLevel: effort,
|
|
285
|
+
extensionAllowlist: fleet.spec.config.worker_extensions,
|
|
189
286
|
sessionFactory: resolvedModel ? sessionFactoryForModel(resolvedModel) : undefined,
|
|
190
287
|
onSession: (s) => registerNodeSession(fleet, nodeId, s),
|
|
191
288
|
onEvent: (e) => {
|
|
@@ -193,7 +290,7 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
193
290
|
if (e.type === "tokens") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { tokens: e.tokens });
|
|
194
291
|
if (e.type === "cost") {
|
|
195
292
|
fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { cost_usd_estimate: e.cost });
|
|
196
|
-
|
|
293
|
+
checkCostLimits(fleet, ctx);
|
|
197
294
|
}
|
|
198
295
|
if (e.type === "error") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { status_note: e.message });
|
|
199
296
|
updateWidget(ctx, fleet);
|
|
@@ -223,11 +320,12 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
223
320
|
killSwitch: fleet.killSwitch,
|
|
224
321
|
pauseSwitch: fleet.pauseSwitch,
|
|
225
322
|
nodeKills: fleet.killedNodes,
|
|
323
|
+
relaunchRequests: fleet.relaunchRequests,
|
|
226
324
|
onNodeChange: (nodeId, nodeState) => {
|
|
227
325
|
fleet.state = fleet.state.nodes[nodeId]
|
|
228
326
|
? patchNode(fleet.fleetRoot, fleet.state, nodeId, nodeState)
|
|
229
327
|
: { ...fleet.state, nodes: { ...fleet.state.nodes, [nodeId]: nodeState } };
|
|
230
|
-
|
|
328
|
+
checkCostLimits(fleet, ctx);
|
|
231
329
|
updateWidget(ctx, fleet);
|
|
232
330
|
},
|
|
233
331
|
onNodeAdded: (w) => {
|
|
@@ -254,9 +352,10 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
254
352
|
});
|
|
255
353
|
fleet.state = state;
|
|
256
354
|
fleet.running = false;
|
|
257
|
-
|
|
355
|
+
cleanup();
|
|
258
356
|
updateWidget(ctx, fleet); // keep final per-node stats visible (todo #12)
|
|
259
|
-
|
|
357
|
+
await writeReport({ spec: fleet.spec, state, fleetRoot: fleet.fleetRoot, repoCwd: ctx.cwd });
|
|
358
|
+
await writeFinalState();
|
|
260
359
|
const last = state.iterations[state.iterations.length - 1];
|
|
261
360
|
if (state.status === "paused" && last?.verdict === "escalate") {
|
|
262
361
|
if (ctx.hasUI) ctx.ui.notify(`fleet paused: reviewer escalated; report: ${join(fleet.fleetRoot, "report.md")}`, "warning");
|
|
@@ -265,8 +364,9 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
265
364
|
}
|
|
266
365
|
} catch (err: unknown) {
|
|
267
366
|
fleet.running = false;
|
|
268
|
-
|
|
367
|
+
cleanup();
|
|
269
368
|
updateWidget(ctx, fleet);
|
|
369
|
+
await writeFinalState();
|
|
270
370
|
const error = err instanceof Error ? err.message : String(err);
|
|
271
371
|
if (ctx.hasUI) ctx.ui.notify(`fleet failed: ${error}`, "error");
|
|
272
372
|
}
|
|
@@ -294,7 +394,7 @@ export async function killFleet(target: string, cwd?: string): Promise<string> {
|
|
|
294
394
|
const worker = active.spec.workers.find((w) => w.id === target);
|
|
295
395
|
const node = active.state.nodes[target];
|
|
296
396
|
if (!worker || !node) return `unknown node "${target}"`;
|
|
297
|
-
if (TERMINAL_NODE_STATUSES.has(node.status)) return `node "${target}" already ${node.status}`;
|
|
397
|
+
if (TERMINAL_NODE_STATUSES.has(node.status) && node.status !== "blocked") return `node "${target}" already ${node.status}`;
|
|
298
398
|
active.killedNodes.add(target);
|
|
299
399
|
const session = active.sessions.get(target);
|
|
300
400
|
if (session) {
|
package/src/dag.ts
CHANGED
|
@@ -107,6 +107,10 @@ export function validateFleetSpec(
|
|
|
107
107
|
errors.push(`config.effort must be one of ${THINKING_LEVELS.join(", ")}`);
|
|
108
108
|
}
|
|
109
109
|
const warnCost = typeof cfg.warn_cost_usd === "number" ? cfg.warn_cost_usd : undefined;
|
|
110
|
+
const maxCost = typeof cfg.max_cost_usd === "number" ? cfg.max_cost_usd : undefined;
|
|
111
|
+
const workerExtensions = Array.isArray(cfg.worker_extensions)
|
|
112
|
+
? (cfg.worker_extensions as unknown[]).filter((e): e is string => typeof e === "string")
|
|
113
|
+
: undefined;
|
|
110
114
|
|
|
111
115
|
const rawWorkers = Array.isArray(r?.workers) ? (r.workers as Record<string, unknown>[]) : [];
|
|
112
116
|
if (rawWorkers.length === 0) errors.push("at least one worker required");
|
|
@@ -266,7 +270,7 @@ export function validateFleetSpec(
|
|
|
266
270
|
const spec: FleetSpec = {
|
|
267
271
|
fleet_name: String(r?.fleet_name ?? ""),
|
|
268
272
|
type: "dag",
|
|
269
|
-
config: { max_concurrent: maxConcurrent, model, effort, warn_cost_usd: warnCost, loop: loopConfig },
|
|
273
|
+
config: { max_concurrent: maxConcurrent, model, effort, warn_cost_usd: warnCost, max_cost_usd: maxCost, worker_extensions: workerExtensions, loop: loopConfig },
|
|
270
274
|
workers,
|
|
271
275
|
};
|
|
272
276
|
|