pi-agent-fleet 0.6.0 → 0.7.1
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 +20 -9
- package/src/canvas-layout.ts +73 -10
- package/src/canvas.ts +52 -25
- package/src/controller.ts +70 -14
- package/src/dag.ts +5 -1
- package/src/edits.ts +21 -2
- package/src/runner.ts +63 -2
- package/src/scheduler.ts +124 -10
- package/src/state.ts +2 -0
- package/src/tools.ts +4 -2
- package/src/types.ts +9 -0
package/package.json
CHANGED
package/src/canvas-client.tsx
CHANGED
|
@@ -90,6 +90,9 @@ function minimapColor(status: string): string {
|
|
|
90
90
|
if (status === "killed" || status === "blocked") return cssVar("--wire");
|
|
91
91
|
return cssVar("--line");
|
|
92
92
|
}
|
|
93
|
+
function shortModel(model: string): string {
|
|
94
|
+
return model.split("/").pop()?.replace(/^deepseek-/, "") || model;
|
|
95
|
+
}
|
|
93
96
|
function j<T>(u: string): Promise<T> {
|
|
94
97
|
return fetch(u).then((r) => {
|
|
95
98
|
if (!r.ok) throw new Error(String(r.status));
|
|
@@ -103,6 +106,7 @@ type FleetNodeData = {
|
|
|
103
106
|
selected: boolean;
|
|
104
107
|
demo: boolean;
|
|
105
108
|
gate: boolean;
|
|
109
|
+
nodeRole: "start" | "end" | "gate" | "normal";
|
|
106
110
|
fleet: string | null;
|
|
107
111
|
onOpen: (id: string) => void;
|
|
108
112
|
};
|
|
@@ -110,6 +114,7 @@ type FleetNodeData = {
|
|
|
110
114
|
function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
|
|
111
115
|
const { view: n, selected } = data;
|
|
112
116
|
const running = n.status === "running";
|
|
117
|
+
const roleLabel = data.nodeRole === "start" ? "START" : data.nodeRole === "end" ? "END" : data.nodeRole === "gate" ? "GATE" : null;
|
|
113
118
|
|
|
114
119
|
const flags: Array<{ label: string; title: string }> = [];
|
|
115
120
|
if (data.gate) flags.push({ label: "⟳ loop gate", title: "Reviewer gate: its verdict decides whether the fleet iterates again" });
|
|
@@ -129,7 +134,7 @@ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
|
|
|
129
134
|
|
|
130
135
|
return (
|
|
131
136
|
<div
|
|
132
|
-
className={"node " + statusClass(n.status) + (selected ? " sel" : "")}
|
|
137
|
+
className={"node " + statusClass(n.status) + " role-" + data.nodeRole + (selected ? " sel" : "")}
|
|
133
138
|
data-node-id={n.id}
|
|
134
139
|
role="button"
|
|
135
140
|
tabIndex={0}
|
|
@@ -138,28 +143,28 @@ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
|
|
|
138
143
|
onClick={activate}
|
|
139
144
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activate(); } }}
|
|
140
145
|
>
|
|
141
|
-
<Handle type="target" position={Position.
|
|
142
|
-
<Handle type="target" position={Position.
|
|
146
|
+
<Handle type="target" position={Position.Left} />
|
|
147
|
+
<Handle type="target" position={Position.Bottom} id="loopIn" />
|
|
143
148
|
<div className="card-body">
|
|
144
149
|
<div className="node-header">
|
|
145
150
|
<span className={"node-dot" + (running ? " pulse" : "")} style={{ background: minimapColor(n.status) }} aria-hidden="true" />
|
|
146
151
|
<span className="id" title={n.id}>{n.id}</span>
|
|
152
|
+
{roleLabel && <span className="role-badge">{roleLabel}</span>}
|
|
147
153
|
<span className="badge">{n.type}</span>
|
|
148
154
|
</div>
|
|
149
155
|
<div className="status-row">
|
|
150
156
|
{running && <span className="spinner" />}
|
|
151
157
|
<span className="st-word">{n.status}</span>
|
|
152
158
|
{n.effort && <><span>·</span><span>{n.effort}</span></>}
|
|
153
|
-
<span>·</span><span>{n.model}</span>
|
|
159
|
+
<span>·</span><span title={n.model}>{shortModel(n.model)}</span>
|
|
154
160
|
</div>
|
|
155
161
|
<div className="stats">
|
|
156
162
|
{(n.turns | 0)} turns · {(Number(n.tokens || 0) / 1000).toFixed(1)}k tok · ${Number(n.cost_usd_estimate || 0).toFixed(2)}
|
|
157
163
|
</div>
|
|
158
164
|
{n.outputs?.length > 0 && (
|
|
159
165
|
<div className="outputs">
|
|
160
|
-
{n.outputs.map((o
|
|
161
|
-
|
|
162
|
-
))}
|
|
166
|
+
<span className="out-count" title={n.outputs.map((o) => `${o.path} · ${o.kind}`).join("\n")}>{n.outputs.length} output{n.outputs.length === 1 ? "" : "s"}</span>
|
|
167
|
+
<span className="out-chip">{n.outputs[0].kind}</span>
|
|
163
168
|
</div>
|
|
164
169
|
)}
|
|
165
170
|
{flags.length > 0 && (
|
|
@@ -172,7 +177,7 @@ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
|
|
|
172
177
|
{n.status_note && <div className="note nodrag">{n.status_note}</div>}
|
|
173
178
|
{failReason && <div className="fail-reason nodrag">{failReason}</div>}
|
|
174
179
|
</div>
|
|
175
|
-
<Handle type="source" position={Position.
|
|
180
|
+
<Handle type="source" position={Position.Right} />
|
|
176
181
|
<Handle type="source" position={Position.Bottom} id="loop" />
|
|
177
182
|
</div>
|
|
178
183
|
);
|
|
@@ -573,12 +578,14 @@ function Flow() {
|
|
|
573
578
|
const gateId = payload.loop
|
|
574
579
|
? (payload.nodes.find((n) => n.id === payload.loop!.gate) ?? payload.nodes.find((n) => n.type === payload.loop!.gate))?.id
|
|
575
580
|
: undefined;
|
|
581
|
+
const hasOutgoing = new Set(payload.edges.map((e) => e.from));
|
|
576
582
|
setNodes((prev) => {
|
|
577
583
|
const byId: Record<string, Node<FleetNodeData>> = {};
|
|
578
584
|
prev.forEach((n) => { byId[n.id] = n; });
|
|
579
585
|
return payload.nodes.map((v) => {
|
|
580
586
|
const existing = byId[v.id];
|
|
581
|
-
const
|
|
587
|
+
const nodeRole: FleetNodeData["nodeRole"] = v.id === gateId ? "gate" : v.depends_on.length === 0 ? "start" : !hasOutgoing.has(v.id) ? "end" : "normal";
|
|
588
|
+
const data: FleetNodeData = { view: v, selected: selected === v.id, demo, gate: v.id === gateId, nodeRole, fleet, onOpen };
|
|
582
589
|
return existing
|
|
583
590
|
? { ...existing, data }
|
|
584
591
|
: { id: v.id, type: "fleet", position: pos[v.id] ?? { x: 0, y: 0 }, data, width: NODE_W };
|
|
@@ -593,7 +600,11 @@ function Flow() {
|
|
|
593
600
|
id: "e" + i,
|
|
594
601
|
source: e.from,
|
|
595
602
|
target: e.to,
|
|
603
|
+
type: "smoothstep",
|
|
604
|
+
pathOptions: { borderRadius: 18 },
|
|
596
605
|
animated: payload.nodes.find((n) => n.id === e.to)?.status === "running",
|
|
606
|
+
style: { strokeWidth: 1.6 },
|
|
607
|
+
markerEnd: { type: MarkerType.ArrowClosed, color: "var(--edge)" } as Edge["markerEnd"],
|
|
597
608
|
}));
|
|
598
609
|
|
|
599
610
|
// feedback loop: the gate node re-triggers the iterate roots each iteration
|
package/src/canvas-layout.ts
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
|
-
export const NODE_W =
|
|
2
|
-
export const NODE_H_GAP =
|
|
3
|
-
export const NODE_W_GAP =
|
|
1
|
+
export const NODE_W = 304;
|
|
2
|
+
export const NODE_H_GAP = 44;
|
|
3
|
+
export const NODE_W_GAP = 156;
|
|
4
|
+
|
|
5
|
+
export interface LayoutNode {
|
|
6
|
+
id: string;
|
|
7
|
+
type?: string;
|
|
8
|
+
outputs?: Array<unknown>;
|
|
9
|
+
depends_on?: string[];
|
|
10
|
+
}
|
|
4
11
|
|
|
5
12
|
export function excerptText(text: string, max: number): { excerpt: string; truncated: boolean } {
|
|
6
13
|
const truncated = text.length > max;
|
|
7
14
|
return { excerpt: truncated ? text.slice(0, max) + "…" : text, truncated };
|
|
8
15
|
}
|
|
9
16
|
|
|
17
|
+
export function estimateNodeHeight(node: LayoutNode): number {
|
|
18
|
+
const outputs = node.outputs?.length ?? 0;
|
|
19
|
+
const outputRows = outputs > 0 ? 1 : 0;
|
|
20
|
+
const gateRow = node.type === "reviewer" ? 18 : 0;
|
|
21
|
+
return 126 + outputRows * 26 + gateRow;
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
export function topoLayers(ids: string[], edges: Array<{ from: string; to: string }>): string[][] {
|
|
11
25
|
const indeg: Record<string, number> = {};
|
|
12
26
|
const nextById: Record<string, string[]> = {};
|
|
@@ -64,20 +78,69 @@ export function reduceCrossings(layers: string[][], edges: Array<{ from: string;
|
|
|
64
78
|
return ordered;
|
|
65
79
|
}
|
|
66
80
|
|
|
81
|
+
function longestPathStages(ids: string[], edges: Array<{ from: string; to: string }>): string[][] {
|
|
82
|
+
const indeg: Record<string, number> = {};
|
|
83
|
+
const nextById: Record<string, string[]> = {};
|
|
84
|
+
const rank: Record<string, number> = {};
|
|
85
|
+
for (const id of ids) {
|
|
86
|
+
indeg[id] = 0;
|
|
87
|
+
nextById[id] = [];
|
|
88
|
+
rank[id] = 0;
|
|
89
|
+
}
|
|
90
|
+
for (const edge of edges) {
|
|
91
|
+
if (!(edge.from in indeg) || !(edge.to in indeg)) continue;
|
|
92
|
+
indeg[edge.to]++;
|
|
93
|
+
nextById[edge.from].push(edge.to);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const queue = ids.filter((id) => indeg[id] === 0);
|
|
97
|
+
const seen = new Set<string>();
|
|
98
|
+
for (let qi = 0; qi < queue.length; qi++) {
|
|
99
|
+
const id = queue[qi];
|
|
100
|
+
seen.add(id);
|
|
101
|
+
for (const to of nextById[id]) {
|
|
102
|
+
rank[to] = Math.max(rank[to], rank[id] + 1);
|
|
103
|
+
indeg[to]--;
|
|
104
|
+
if (indeg[to] === 0) queue.push(to);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const id of ids) {
|
|
108
|
+
if (!seen.has(id)) rank[id] = Math.max(...Object.values(rank), 0) + 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const rightmost = Math.max(...Object.values(rank), 0);
|
|
112
|
+
const hasOutgoing = new Set(edges.filter((edge) => edge.from in rank && edge.to in rank).map((edge) => edge.from));
|
|
113
|
+
for (const id of ids) {
|
|
114
|
+
if (!hasOutgoing.has(id)) rank[id] = rightmost;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const stages: string[][] = [];
|
|
118
|
+
for (const id of ids) (stages[rank[id]] ??= []).push(id);
|
|
119
|
+
return stages.filter(Boolean);
|
|
120
|
+
}
|
|
121
|
+
|
|
67
122
|
export function computePositions(
|
|
68
|
-
nodes:
|
|
123
|
+
nodes: LayoutNode[],
|
|
69
124
|
edges: Array<{ from: string; to: string }>,
|
|
70
125
|
): Record<string, { x: number; y: number }> {
|
|
71
|
-
const
|
|
72
|
-
const
|
|
126
|
+
const byId = Object.fromEntries(nodes.map((node) => [node.id, node]));
|
|
127
|
+
const stages = reduceCrossings(longestPathStages(nodes.map((node) => node.id), edges), edges);
|
|
73
128
|
const stepX = NODE_W + NODE_W_GAP;
|
|
129
|
+
const heights = Object.fromEntries(nodes.map((node) => [node.id, estimateNodeHeight(node)]));
|
|
130
|
+
const stageHeights = stages.map((stage) => stage.reduce((sum, id, i) => sum + heights[id] + (i ? NODE_H_GAP : 0), 0));
|
|
131
|
+
const maxStageHeight = Math.max(...stageHeights, 0);
|
|
74
132
|
const pos: Record<string, { x: number; y: number }> = {};
|
|
75
133
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
134
|
+
stages.forEach((stage, si) => {
|
|
135
|
+
let y = Math.max(0, (maxStageHeight - stageHeights[si]) / 2);
|
|
136
|
+
stage.forEach((id) => {
|
|
137
|
+
const node = byId[id];
|
|
138
|
+
const fan = edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
139
|
+
const repel = Math.min(fan * 3, 24);
|
|
140
|
+
pos[id] = { x: si * stepX, y };
|
|
141
|
+
y += estimateNodeHeight(node) + NODE_H_GAP + repel;
|
|
80
142
|
});
|
|
81
143
|
});
|
|
144
|
+
|
|
82
145
|
return pos;
|
|
83
146
|
}
|
package/src/canvas.ts
CHANGED
|
@@ -508,6 +508,15 @@ export interface SessionTailView {
|
|
|
508
508
|
events: TimelineEvent[];
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
+
function compactToolArguments(value: unknown): unknown {
|
|
512
|
+
if (typeof value === "string") return value.length > 1000 ? `[omitted ${value.length} chars]` : value;
|
|
513
|
+
if (Array.isArray(value)) return value.map(compactToolArguments);
|
|
514
|
+
if (value && typeof value === "object") {
|
|
515
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, compactToolArguments(v)]));
|
|
516
|
+
}
|
|
517
|
+
return value;
|
|
518
|
+
}
|
|
519
|
+
|
|
511
520
|
export function parseSessionTail(jsonl: string, maxEntries: number): SessionTailView {
|
|
512
521
|
const events: TimelineEvent[] = [];
|
|
513
522
|
const entries: SessionEntryView[] = [];
|
|
@@ -545,9 +554,10 @@ export function parseSessionTail(jsonl: string, maxEntries: number): SessionTail
|
|
|
545
554
|
if (p.type === "text" && typeof p.text === "string") parts.push(p.text);
|
|
546
555
|
else if ((p.type === "toolCall" || p.type === "tool_call") && typeof p.name === "string") {
|
|
547
556
|
parts.push(`[tool: ${p.name}]`);
|
|
548
|
-
const
|
|
557
|
+
const args = compactToolArguments(p.arguments) as Record<string, unknown> | undefined;
|
|
558
|
+
const a: ActionView = { type: "tool_call", name: p.name, arguments: args, timestamp: ts };
|
|
549
559
|
actions.push(a);
|
|
550
|
-
events.push({ type: "tool_call", name: p.name, arguments:
|
|
560
|
+
events.push({ type: "tool_call", name: p.name, arguments: args, timestamp: ts });
|
|
551
561
|
} else if (p.type === "toolResult" || p.type === "tool_result") {
|
|
552
562
|
parts.push("[tool result]");
|
|
553
563
|
const a: ActionView = { type: "tool_result", toolName: typeof p.toolName === "string" ? p.toolName : p.name, isError: p.isError, timestamp: ts };
|
|
@@ -649,8 +659,8 @@ function readFileSyncSafe(p: string): string {
|
|
|
649
659
|
}
|
|
650
660
|
|
|
651
661
|
const PAGE_CSS = `
|
|
652
|
-
:root { color-scheme: dark; --bg:#0d1117; --fg:#c9d1d9; --muted:#8b949e; --line:#30363d; --panel:#
|
|
653
|
-
[data-theme="light"] { color-scheme: light; --bg:#f6f8fa; --fg:#1f2328; --muted:#57606a; --line:#
|
|
662
|
+
:root { color-scheme: dark; --bg:#0d1117; --fg:#c9d1d9; --muted:#8b949e; --line:#30363d; --panel:#171d26; --panel-2:#212936; --stage-bg:#0a0c10; --accent:#58a6ff; --ok:#3fb950; --bad:#f85149; --warn:#d29922; --wire:#6e7681; --hdr:#f0f6fc; --card-shadow:0 1px 2px rgba(0,0,0,0.42), 0 12px 28px -12px rgba(0,0,0,0.72); --card-shadow-lg:0 2px 4px rgba(0,0,0,0.5), 0 18px 42px -14px rgba(0,0,0,0.78); --edge:#4a5361; --edge-soft:#363d49; --mm-mask:rgba(8,10,14,0.55); --mm-frame:rgba(255,255,255,0.14); }
|
|
663
|
+
[data-theme="light"] { color-scheme: light; --bg:#f6f8fa; --fg:#1f2328; --muted:#57606a; --line:#d8dee7; --panel:#ffffff; --panel-2:#f6f8fa; --stage-bg:#f2f5f9; --accent:#0969da; --ok:#1a7f37; --bad:#cf222e; --warn:#9a6700; --wire:#8c959f; --hdr:#1f2328; --card-shadow:0 1px 2px rgba(15,23,42,0.08), 0 14px 30px -16px rgba(15,23,42,0.26); --card-shadow-lg:0 2px 4px rgba(15,23,42,0.10), 0 18px 44px -16px rgba(15,23,42,0.30); --edge:#aeb8c6; --edge-soft:#d0d7de; --mm-mask:rgba(15,23,42,0.10); --mm-frame:rgba(9,105,218,0.35); }
|
|
654
664
|
:root { --ui:system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --mono:"SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
|
|
655
665
|
* { box-sizing:border-box; }
|
|
656
666
|
html,body,#root { margin:0; height:100%; }
|
|
@@ -687,43 +697,60 @@ button:hover, select:hover { border-color:var(--accent); }
|
|
|
687
697
|
.taskbox-side-label { font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.04em; margin-bottom:4px; }
|
|
688
698
|
main { display:flex; flex:1 1 auto; min-height:0; }
|
|
689
699
|
#stage { flex:1; position:relative; min-width:0; background:var(--stage-bg); }
|
|
690
|
-
.react-flow { background:var(--stage-bg); }
|
|
691
|
-
.react-flow__node { width:
|
|
700
|
+
.react-flow { background:radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--muted) 18%, transparent) 1px, transparent 0) 0 0/24px 24px, var(--stage-bg); }
|
|
701
|
+
.react-flow__node { width:304px; }
|
|
692
702
|
.react-flow__handle { width:6px; height:6px; background:var(--wire); border:none; opacity:0; }
|
|
693
|
-
.react-flow__edge-path { stroke:var(--edge); stroke-width:1.
|
|
694
|
-
.react-flow__edge:hover .react-flow__edge-path { stroke:var(--accent); stroke-width:2; }
|
|
695
|
-
.react-flow__edge.animated .react-flow__edge-path { stroke:var(--accent); stroke-width:2; stroke-dasharray:1
|
|
696
|
-
@keyframes dashflow { to { stroke-dashoffset:-
|
|
703
|
+
.react-flow__edge-path { stroke:var(--edge); stroke-width:1.6; stroke-linecap:round; stroke-linejoin:round; opacity:0.56; }
|
|
704
|
+
.react-flow__edge:hover .react-flow__edge-path { stroke:var(--accent); stroke-width:2.2; opacity:0.95; }
|
|
705
|
+
.react-flow__edge.animated .react-flow__edge-path { stroke:var(--accent); stroke-width:2.2; stroke-dasharray:1 8; opacity:0.95; animation:dashflow 0.7s linear infinite; }
|
|
706
|
+
@keyframes dashflow { to { stroke-dashoffset:-18; } }
|
|
697
707
|
.react-flow__controls button { background:var(--panel); color:var(--fg); border-bottom:1px solid var(--line); fill:var(--fg); }
|
|
698
708
|
.react-flow__minimap { width:172px; height:112px; background:var(--panel); border-radius:12px; box-shadow:var(--card-shadow); overflow:hidden; }
|
|
699
709
|
.react-flow__minimap svg { border-radius:10px; }
|
|
700
710
|
.react-flow__minimap-mask { fill:var(--mm-mask) !important; stroke:none; }
|
|
701
711
|
.react-flow__minimap-node { stroke:var(--panel) !important; stroke-width:4px !important; rx:3; ry:3; }
|
|
702
712
|
.react-flow__attribution { display:none; }
|
|
703
|
-
.node { position:relative; width:
|
|
713
|
+
.node { position:relative; width:304px; border-radius:14px; background:var(--panel); cursor:pointer; box-shadow:var(--card-shadow); overflow:hidden; transition:box-shadow 0.18s ease, transform 0.18s ease, background 0.18s ease; }
|
|
714
|
+
.node::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--wire); opacity:0.7; }
|
|
704
715
|
.node:hover { box-shadow:var(--card-shadow-lg); transform:translateY(-2px); }
|
|
705
|
-
.node.sel { background:color-mix(in srgb, var(--accent)
|
|
706
|
-
.node.sel.st-failed, .node.sel.st-contract_failed { background:color-mix(in srgb, var(--bad)
|
|
716
|
+
.node.sel { background:color-mix(in srgb, var(--accent) 12%, var(--panel)); box-shadow:var(--card-shadow-lg); transform:translateY(-2px); }
|
|
717
|
+
.node.sel.st-failed, .node.sel.st-contract_failed { background:color-mix(in srgb, var(--bad) 12%, var(--panel)); }
|
|
707
718
|
/* keyboard focus ring only — never on mouse click */
|
|
708
719
|
.node:focus:not(:focus-visible) { outline:none; }
|
|
709
|
-
.node.st-running { background:color-mix(in srgb, var(--accent)
|
|
710
|
-
.node.st-
|
|
711
|
-
.
|
|
720
|
+
.node.st-running { background:color-mix(in srgb, var(--accent) 8%, var(--panel)); }
|
|
721
|
+
.node.st-completed::before { background:var(--ok); }
|
|
722
|
+
.node.st-running::before { background:var(--accent); }
|
|
723
|
+
.node.st-failed::before, .node.st-contract_failed::before { background:var(--bad); }
|
|
724
|
+
.node.st-pending::before, .node.st-ready::before { background:var(--line); }
|
|
725
|
+
.node.role-start { box-shadow:var(--card-shadow), inset 0 0 0 1px color-mix(in srgb, var(--ok) 24%, transparent); }
|
|
726
|
+
.node.role-end { box-shadow:var(--card-shadow), inset 0 0 0 1px color-mix(in srgb, var(--accent) 24%, transparent); }
|
|
727
|
+
.node.role-gate { box-shadow:var(--card-shadow), inset 0 0 0 1px color-mix(in srgb, var(--warn) 38%, transparent); }
|
|
728
|
+
.node.role-start::after, .node.role-end::after, .node.role-gate::after { content:""; position:absolute; inset:auto 12px 0 12px; height:1px; background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 45%, transparent), transparent); opacity:0.75; }
|
|
729
|
+
.node.role-start::after { background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--ok) 45%, transparent), transparent); }
|
|
730
|
+
.node.role-gate::after { background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--warn) 55%, transparent), transparent); }
|
|
731
|
+
.node.st-failed, .node.st-contract_failed { background:color-mix(in srgb, var(--bad) 9%, var(--panel)); }
|
|
732
|
+
.card-body { padding:15px 16px 14px 17px; }
|
|
712
733
|
.node-header { display:flex; align-items:center; gap:7px; }
|
|
713
734
|
.node-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
|
714
735
|
.node-dot.pulse { animation:pulse 1.6s ease-in-out infinite; }
|
|
715
736
|
.node-header .id { flex:1; }
|
|
716
737
|
.node-header .badge { margin-left:auto; }
|
|
717
|
-
.id { font-weight:
|
|
718
|
-
.badge { font-size:11px; padding:1px 7px; border:1px solid var(--line); border-radius:10px; color:var(--muted); white-space:nowrap; }
|
|
719
|
-
.
|
|
720
|
-
.
|
|
738
|
+
.id { font-weight:760; font-size:16px; letter-spacing:-0.02em; color:var(--hdr); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
739
|
+
.badge, .role-badge { font-size:11px; padding:1px 7px; border:1px solid var(--line); border-radius:10px; color:var(--muted); white-space:nowrap; background:color-mix(in srgb, var(--panel-2) 70%, transparent); }
|
|
740
|
+
.role-badge { font-weight:700; letter-spacing:0.04em; color:var(--fg); }
|
|
741
|
+
.role-start .role-badge { border-color:color-mix(in srgb, var(--ok) 55%, var(--line)); color:var(--ok); }
|
|
742
|
+
.role-end .role-badge { border-color:color-mix(in srgb, var(--accent) 55%, var(--line)); color:var(--accent); }
|
|
743
|
+
.role-gate .role-badge { border-color:color-mix(in srgb, var(--warn) 60%, var(--line)); color:var(--warn); }
|
|
744
|
+
.status-row { margin-top:9px; display:flex; align-items:center; gap:6px; font-size:13px; color:var(--muted); min-width:0; }
|
|
745
|
+
.status-row span:last-child { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
746
|
+
.status-row .st-word { color:var(--fg); font-weight:650; }
|
|
721
747
|
.spinner { width:10px; height:10px; border:2px solid var(--accent); border-top-color:transparent; border-radius:50%; animation:spin 1s linear infinite; display:inline-block; flex-shrink:0; }
|
|
722
748
|
@keyframes spin { to { transform:rotate(360deg); } }
|
|
723
|
-
.stats { margin-top:
|
|
724
|
-
.outputs { margin-top:6px; }
|
|
725
|
-
.out-chip { display:inline-block; font-size:11px; line-height:1.
|
|
726
|
-
.
|
|
749
|
+
.stats { margin-top:6px; font-size:13px; color:var(--muted); }
|
|
750
|
+
.outputs { margin-top:10px; display:flex; gap:6px; align-items:center; }
|
|
751
|
+
.out-count, .out-chip { display:inline-block; font-size:11px; line-height:1.45; border:1px solid var(--line); border-radius:8px; padding:4px 8px; color:var(--muted); background:var(--bg); }
|
|
752
|
+
.out-count { color:var(--fg); background:color-mix(in srgb, var(--accent) 8%, var(--bg)); }
|
|
753
|
+
.flags { margin-top:8px; font-size:11px; color:var(--warn); }
|
|
727
754
|
.flags span { cursor:help; }
|
|
728
755
|
.note { margin-top:6px; font-size:13px; color:var(--warn); }
|
|
729
756
|
.note, .fail-reason { user-select:text; cursor:text; }
|
|
@@ -750,7 +777,7 @@ main { display:flex; flex:1 1 auto; min-height:0; }
|
|
|
750
777
|
.timeline { margin-bottom:12px; }
|
|
751
778
|
.timeline-action { background:var(--panel); border-radius:5px; margin-bottom:5px; }
|
|
752
779
|
.timeline-action.open { background:var(--panel-2); }
|
|
753
|
-
.timeline-action.action-error {
|
|
780
|
+
.timeline-action.action-error { box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--bad) 40%, transparent); background:color-mix(in srgb, var(--bad) 9%, var(--panel)); }
|
|
754
781
|
.timeline-row { display:flex; align-items:center; gap:8px; padding:5px 8px; font-size:12px; }
|
|
755
782
|
.timeline-row .action-icon { flex-shrink:0; width:34px; text-align:center; color:var(--accent); font-family:var(--mono); font-size:11px; text-transform:uppercase; letter-spacing:0.02em; }
|
|
756
783
|
.timeline-row .action-name { flex-shrink:0; font-weight:600; min-width:48px; font-size:12px; white-space:nowrap; }
|
package/src/controller.ts
CHANGED
|
@@ -70,7 +70,37 @@ export async function statusText(fleet: ActiveFleet): Promise<string> {
|
|
|
70
70
|
: failed ? `next: fleet_relaunch ${failed[0]}`
|
|
71
71
|
: state.status === "completed" ? `next: read report ${reportPath}`
|
|
72
72
|
: `next: inspect ${join(fleet.fleetRoot, "state.json")}`;
|
|
73
|
-
|
|
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
|
+
}
|
|
74
104
|
}
|
|
75
105
|
|
|
76
106
|
export async function dagPreview(spec: FleetSpec, state: FleetState | undefined, fleetRoot: string): Promise<string> {
|
|
@@ -180,17 +210,40 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
180
210
|
|
|
181
211
|
fleet.running = true;
|
|
182
212
|
fleet.costWarned = false;
|
|
183
|
-
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);
|
|
184
215
|
const stopSpinner = startSpinner(ctx, fleet);
|
|
185
216
|
updateWidget(ctx, fleet);
|
|
186
217
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
194
247
|
}
|
|
195
248
|
};
|
|
196
249
|
|
|
@@ -229,6 +282,7 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
229
282
|
repoCwd: worktreeCwd,
|
|
230
283
|
sessionDir,
|
|
231
284
|
thinkingLevel: effort,
|
|
285
|
+
extensionAllowlist: fleet.spec.config.worker_extensions,
|
|
232
286
|
sessionFactory: resolvedModel ? sessionFactoryForModel(resolvedModel) : undefined,
|
|
233
287
|
onSession: (s) => registerNodeSession(fleet, nodeId, s),
|
|
234
288
|
onEvent: (e) => {
|
|
@@ -236,7 +290,7 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
236
290
|
if (e.type === "tokens") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { tokens: e.tokens });
|
|
237
291
|
if (e.type === "cost") {
|
|
238
292
|
fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { cost_usd_estimate: e.cost });
|
|
239
|
-
|
|
293
|
+
checkCostLimits(fleet, ctx);
|
|
240
294
|
}
|
|
241
295
|
if (e.type === "error") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { status_note: e.message });
|
|
242
296
|
updateWidget(ctx, fleet);
|
|
@@ -271,7 +325,7 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
271
325
|
fleet.state = fleet.state.nodes[nodeId]
|
|
272
326
|
? patchNode(fleet.fleetRoot, fleet.state, nodeId, nodeState)
|
|
273
327
|
: { ...fleet.state, nodes: { ...fleet.state.nodes, [nodeId]: nodeState } };
|
|
274
|
-
|
|
328
|
+
checkCostLimits(fleet, ctx);
|
|
275
329
|
updateWidget(ctx, fleet);
|
|
276
330
|
},
|
|
277
331
|
onNodeAdded: (w) => {
|
|
@@ -298,9 +352,10 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
298
352
|
});
|
|
299
353
|
fleet.state = state;
|
|
300
354
|
fleet.running = false;
|
|
301
|
-
|
|
355
|
+
cleanup();
|
|
302
356
|
updateWidget(ctx, fleet); // keep final per-node stats visible (todo #12)
|
|
303
|
-
|
|
357
|
+
await writeReport({ spec: fleet.spec, state, fleetRoot: fleet.fleetRoot, repoCwd: ctx.cwd });
|
|
358
|
+
await writeFinalState();
|
|
304
359
|
const last = state.iterations[state.iterations.length - 1];
|
|
305
360
|
if (state.status === "paused" && last?.verdict === "escalate") {
|
|
306
361
|
if (ctx.hasUI) ctx.ui.notify(`fleet paused: reviewer escalated; report: ${join(fleet.fleetRoot, "report.md")}`, "warning");
|
|
@@ -309,8 +364,9 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
|
|
|
309
364
|
}
|
|
310
365
|
} catch (err: unknown) {
|
|
311
366
|
fleet.running = false;
|
|
312
|
-
|
|
367
|
+
cleanup();
|
|
313
368
|
updateWidget(ctx, fleet);
|
|
369
|
+
await writeFinalState();
|
|
314
370
|
const error = err instanceof Error ? err.message : String(err);
|
|
315
371
|
if (ctx.hasUI) ctx.ui.notify(`fleet failed: ${error}`, "error");
|
|
316
372
|
}
|
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
|
|
package/src/edits.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { NodeStatus, ThinkingLevelName } from "./types.js";
|
|
|
8
8
|
import { THINKING_LEVELS } from "./types.js";
|
|
9
9
|
|
|
10
10
|
export type NodeEditKey = "model" | "effort" | "task";
|
|
11
|
-
export type ConfigEditKey = "max_concurrent" | "warn_cost_usd" | "model" | "effort";
|
|
11
|
+
export type ConfigEditKey = "max_concurrent" | "warn_cost_usd" | "max_cost_usd" | "worker_extensions" | "model" | "effort";
|
|
12
12
|
|
|
13
13
|
export interface EditResult {
|
|
14
14
|
ok: boolean;
|
|
@@ -85,6 +85,25 @@ export async function editConfig(
|
|
|
85
85
|
fleet.spec.config.warn_cost_usd = n;
|
|
86
86
|
break;
|
|
87
87
|
}
|
|
88
|
+
case "max_cost_usd": {
|
|
89
|
+
const n = Number(value);
|
|
90
|
+
if (!Number.isFinite(n) || n < 0) return { ok: false, message: "max_cost_usd must be a number >= 0" };
|
|
91
|
+
fleet.spec.config.max_cost_usd = n;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case "worker_extensions": {
|
|
95
|
+
// accept JSON array or comma-separated string
|
|
96
|
+
let list: string[];
|
|
97
|
+
try {
|
|
98
|
+
const parsed: unknown = JSON.parse(value);
|
|
99
|
+
list = Array.isArray(parsed) ? parsed.map(String) : [value];
|
|
100
|
+
} catch {
|
|
101
|
+
list = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
102
|
+
}
|
|
103
|
+
if (list.some((s) => s.length === 0)) return { ok: false, message: "worker_extensions must be non-empty strings" };
|
|
104
|
+
fleet.spec.config.worker_extensions = list;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
88
107
|
case "model": {
|
|
89
108
|
const r = resolveModelReference(registry, value);
|
|
90
109
|
if (!r.ok) return { ok: false, message: r.error };
|
|
@@ -99,7 +118,7 @@ export async function editConfig(
|
|
|
99
118
|
break;
|
|
100
119
|
}
|
|
101
120
|
default:
|
|
102
|
-
return { ok: false, message: `unknown config key "${String(key)}" (keys: max_concurrent, warn_cost_usd, model, effort)` };
|
|
121
|
+
return { ok: false, message: `unknown config key "${String(key)}" (keys: max_concurrent, warn_cost_usd, max_cost_usd, worker_extensions, model, effort)` };
|
|
103
122
|
}
|
|
104
123
|
await persistFleetJson(fleet);
|
|
105
124
|
return { ok: true, message: `config.${key} updated` };
|
package/src/runner.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createAgentSession, SessionManager, type CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { createAgentSession, SessionManager, DefaultResourceLoader, type CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { ThinkingLevelName, WorkerSpec } from "./types.js";
|
|
4
4
|
import { WORKER_TYPE_TOOLS } from "./types.js";
|
|
@@ -25,16 +25,52 @@ export interface SessionOpts {
|
|
|
25
25
|
tools: string[];
|
|
26
26
|
model?: string;
|
|
27
27
|
thinkingLevel?: ThinkingLevelName;
|
|
28
|
+
extensionAllowlist?: string[];
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export type SessionFactory = (opts: SessionOpts) => Promise<AgentSessionLike>;
|
|
31
32
|
|
|
33
|
+
// Fleet workers must not load user extensions/skills: reading global skill files
|
|
34
|
+
// burns tokens mid-task, and extensions like pi-web-access run background async work
|
|
35
|
+
// that can crash the pi process after the session is disposed.
|
|
36
|
+
// Exception: some model providers are registered BY extensions (e.g. opencode-pi
|
|
37
|
+
// registers the opencode-cli provider). worker_extensions allowlists exactly those.
|
|
38
|
+
export function filterExtensionsByAllowlist<T extends { path: string }>(
|
|
39
|
+
extensions: T[],
|
|
40
|
+
allowlist: string[] | undefined,
|
|
41
|
+
): T[] {
|
|
42
|
+
if (!allowlist || allowlist.length === 0) return extensions;
|
|
43
|
+
return extensions.filter((e) => allowlist.some((a) => e.path.includes(a)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function createLeanResourceLoader(cwd: string, extensionAllowlist?: string[]): Promise<DefaultResourceLoader> {
|
|
47
|
+
const allow = extensionAllowlist && extensionAllowlist.length > 0;
|
|
48
|
+
const loader = new DefaultResourceLoader({
|
|
49
|
+
cwd,
|
|
50
|
+
agentDir: cwd,
|
|
51
|
+
noExtensions: !allow,
|
|
52
|
+
extensionsOverride: allow
|
|
53
|
+
? (base) => ({ ...base, extensions: filterExtensionsByAllowlist(base.extensions, extensionAllowlist) })
|
|
54
|
+
: undefined,
|
|
55
|
+
noSkills: true,
|
|
56
|
+
noPromptTemplates: true,
|
|
57
|
+
noThemes: true,
|
|
58
|
+
noContextFiles: true,
|
|
59
|
+
systemPromptOverride: () => undefined,
|
|
60
|
+
appendSystemPromptOverride: () => [],
|
|
61
|
+
});
|
|
62
|
+
await loader.reload();
|
|
63
|
+
return loader;
|
|
64
|
+
}
|
|
65
|
+
|
|
32
66
|
export const defaultSessionFactory: SessionFactory = async (opts) => {
|
|
67
|
+
const resourceLoader = await createLeanResourceLoader(opts.cwd, opts.extensionAllowlist);
|
|
33
68
|
const { session } = await createAgentSession({
|
|
34
69
|
cwd: opts.cwd,
|
|
35
70
|
tools: opts.tools,
|
|
36
71
|
sessionManager: SessionManager.create(opts.cwd, opts.sessionDir),
|
|
37
72
|
thinkingLevel: opts.thinkingLevel as ThinkingLevelOption,
|
|
73
|
+
resourceLoader,
|
|
38
74
|
});
|
|
39
75
|
return session as unknown as AgentSessionLike;
|
|
40
76
|
};
|
|
@@ -49,6 +85,7 @@ export interface RunWorkerOpts {
|
|
|
49
85
|
onSession?: (session: AgentSessionLike) => void;
|
|
50
86
|
sessionFactory?: SessionFactory;
|
|
51
87
|
thinkingLevel?: ThinkingLevelName;
|
|
88
|
+
extensionAllowlist?: string[];
|
|
52
89
|
}
|
|
53
90
|
|
|
54
91
|
export interface RunWorkerResult {
|
|
@@ -69,6 +106,7 @@ export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
|
|
|
69
106
|
tools: WORKER_TYPE_TOOLS[opts.worker.type],
|
|
70
107
|
model: opts.worker.model,
|
|
71
108
|
thinkingLevel: opts.thinkingLevel,
|
|
109
|
+
extensionAllowlist: opts.extensionAllowlist,
|
|
72
110
|
});
|
|
73
111
|
} catch (err) {
|
|
74
112
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -79,13 +117,29 @@ export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
|
|
|
79
117
|
let turns = 0;
|
|
80
118
|
let tokens = 0;
|
|
81
119
|
let cost = 0;
|
|
120
|
+
// pi resolves session.prompt() normally even when the model run ends in an error
|
|
121
|
+
// (e.g. provider usage limit) — the failure is only visible on the final assistant
|
|
122
|
+
// message's stopReason/errorMessage. Track the last assistant message so a resolved
|
|
123
|
+
// prompt that actually errored is reported as ok:false instead of running the output
|
|
124
|
+
// contract against files that were never written.
|
|
125
|
+
let lastStopReason: string | undefined;
|
|
126
|
+
let lastErrorMessage: string | undefined;
|
|
82
127
|
const unsub = session.subscribe((e) => {
|
|
83
128
|
if (e.type === "turn_end") {
|
|
84
129
|
turns++;
|
|
85
130
|
opts.onEvent({ type: "turn", nodeId: opts.nodeId, turns });
|
|
86
131
|
}
|
|
87
132
|
if (e.type === "message_end") {
|
|
88
|
-
const msg = e.message as {
|
|
133
|
+
const msg = e.message as {
|
|
134
|
+
role?: string;
|
|
135
|
+
stopReason?: string;
|
|
136
|
+
errorMessage?: string;
|
|
137
|
+
usage?: { totalTokens?: number; cost?: { total?: number } };
|
|
138
|
+
} | undefined;
|
|
139
|
+
if (msg?.role === "assistant") {
|
|
140
|
+
lastStopReason = msg.stopReason;
|
|
141
|
+
lastErrorMessage = msg.errorMessage;
|
|
142
|
+
}
|
|
89
143
|
if (msg?.role === "assistant" && msg.usage?.totalTokens) {
|
|
90
144
|
tokens += msg.usage.totalTokens;
|
|
91
145
|
opts.onEvent({ type: "tokens", nodeId: opts.nodeId, tokens });
|
|
@@ -98,6 +152,11 @@ export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
|
|
|
98
152
|
});
|
|
99
153
|
try {
|
|
100
154
|
await session.prompt(opts.prompt);
|
|
155
|
+
if (lastStopReason === "error") {
|
|
156
|
+
const message = lastErrorMessage ?? "worker model run ended with an error";
|
|
157
|
+
opts.onEvent({ type: "error", nodeId: opts.nodeId, message });
|
|
158
|
+
return { ok: false, turns, tokens, cost, error: message };
|
|
159
|
+
}
|
|
101
160
|
opts.onEvent({ type: "done", nodeId: opts.nodeId });
|
|
102
161
|
return { ok: true, turns, tokens, cost };
|
|
103
162
|
} catch (err) {
|
|
@@ -112,12 +171,14 @@ export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
|
|
|
112
171
|
|
|
113
172
|
export function sessionFactoryForModel(model: Model<Api>): SessionFactory {
|
|
114
173
|
return async (opts) => {
|
|
174
|
+
const resourceLoader = await createLeanResourceLoader(opts.cwd, opts.extensionAllowlist);
|
|
115
175
|
const { session } = await createAgentSession({
|
|
116
176
|
cwd: opts.cwd,
|
|
117
177
|
tools: opts.tools,
|
|
118
178
|
sessionManager: SessionManager.create(opts.cwd, opts.sessionDir),
|
|
119
179
|
model,
|
|
120
180
|
thinkingLevel: opts.thinkingLevel as ThinkingLevelOption,
|
|
181
|
+
resourceLoader,
|
|
121
182
|
});
|
|
122
183
|
return session as unknown as AgentSessionLike;
|
|
123
184
|
};
|
package/src/scheduler.ts
CHANGED
|
@@ -25,6 +25,15 @@ export interface RunFleetOpts {
|
|
|
25
25
|
continuePass?: boolean;
|
|
26
26
|
onIterationEnd?: (snap: IterationSnapshot) => void;
|
|
27
27
|
prepareIteration?: (n: number, state: FleetState) => Promise<void>;
|
|
28
|
+
retryDelayMs?: (attempt: number) => number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MAX_RETRY_ATTEMPTS = 3;
|
|
32
|
+
const DEFAULT_RETRY_DELAY_MS = (attempt: number): number => 1000 * 2 ** attempt;
|
|
33
|
+
const RETRYABLE_ERROR_RE = /usage limit|rate.?limit|429|overloaded|502|503|timed? ?out|timeout|econnreset|etimedout|econnrefused|fetch failed/i;
|
|
34
|
+
|
|
35
|
+
export function isRetryableError(message: string): boolean {
|
|
36
|
+
return RETRYABLE_ERROR_RE.test(message);
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
const FAILED: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed", "blocked"]);
|
|
@@ -52,6 +61,19 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
52
61
|
let state: FleetState;
|
|
53
62
|
if (opts.resumeFrom) {
|
|
54
63
|
state = { ...opts.resumeFrom, paused: false, status: "running" };
|
|
64
|
+
// Crash recovery: nodes left "running" on disk by a dead process have no live
|
|
65
|
+
// session in this runFleet call — reset them to pending so they get dispatched.
|
|
66
|
+
// Without this, the scheduler can neither dispatch nor terminate them and spins
|
|
67
|
+
// forever (nothing runnable, not all nodes terminal).
|
|
68
|
+
for (const w of spec.workers) {
|
|
69
|
+
if (state.nodes[w.id]?.status === "running") {
|
|
70
|
+
state = patchNode(fleetRoot, state, w.id, {
|
|
71
|
+
status: "pending",
|
|
72
|
+
started_at: undefined,
|
|
73
|
+
status_note: "recovered stale running state from a previous crashed/killed process",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
55
77
|
await writeState(fleetRoot, state);
|
|
56
78
|
if (allNodesTerminal(state, spec) && !opts.continuePass) {
|
|
57
79
|
state = resetForIteration(state, spec);
|
|
@@ -71,6 +93,15 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
71
93
|
};
|
|
72
94
|
|
|
73
95
|
const running = new Set<Promise<void>>();
|
|
96
|
+
const circuitErrors = new Map<string, Set<string>>();
|
|
97
|
+
let circuitOpen = false;
|
|
98
|
+
function recordCircuitError(nodeId: string, error: string | undefined) {
|
|
99
|
+
if (!error) return;
|
|
100
|
+
const set = circuitErrors.get(error) ?? new Set<string>();
|
|
101
|
+
set.add(nodeId);
|
|
102
|
+
circuitErrors.set(error, set);
|
|
103
|
+
if (set.size >= 2) circuitOpen = true;
|
|
104
|
+
}
|
|
74
105
|
|
|
75
106
|
const repoCwdFor = (nodeId: string): string =>
|
|
76
107
|
typeof opts.repoCwd === "function" ? opts.repoCwd(nodeId) : opts.repoCwd;
|
|
@@ -179,6 +210,23 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
179
210
|
await patch(w.id, { status: "killed", ended_at: new Date().toISOString() });
|
|
180
211
|
}
|
|
181
212
|
}
|
|
213
|
+
// circuit breaker: two distinct nodes failed with the identical error
|
|
214
|
+
if (circuitOpen) {
|
|
215
|
+
const [tripError, tripSet] = [...circuitErrors.entries()].find(([_, ids]) => ids.size >= 2) ?? ["", new Set<string>()];
|
|
216
|
+
const tripCount = tripSet.size;
|
|
217
|
+
const note = `circuit breaker: ${tripCount} nodes failed with identical error: ${tripError.slice(0, 80)}`;
|
|
218
|
+
for (const w of spec.workers) {
|
|
219
|
+
const n = state.nodes[w.id];
|
|
220
|
+
if (!n || (n.status !== "pending" && n.status !== "ready")) continue;
|
|
221
|
+
await patch(w.id, { status: "blocked", ended_at: new Date().toISOString(), status_note: note });
|
|
222
|
+
}
|
|
223
|
+
if (running.size > 0) {
|
|
224
|
+
await Promise.race(running);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
|
|
182
230
|
// dispatch ready
|
|
183
231
|
const activeCount = running.size;
|
|
184
232
|
let slots = spec.config.max_concurrent - activeCount;
|
|
@@ -251,16 +299,55 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
251
299
|
|
|
252
300
|
const dispatchMs = Date.now();
|
|
253
301
|
await patch(w.id, { status: "running", started_at: new Date().toISOString() });
|
|
254
|
-
const p =
|
|
302
|
+
const p = (async () => {
|
|
303
|
+
let totals = { turns: 0, tokens: 0, cost: 0 };
|
|
304
|
+
let lastRes: Awaited<ReturnType<typeof opts.spawn>> | undefined;
|
|
305
|
+
let finalError: string | undefined;
|
|
306
|
+
|
|
307
|
+
for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
|
|
308
|
+
if (opts.killSwitch?.killed) return;
|
|
309
|
+
if (opts.nodeKills?.has(w.id)) {
|
|
310
|
+
await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns: totals.turns, tokens: totals.tokens, cost_usd_estimate: totals.cost });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const res = await opts.spawn(w.id);
|
|
314
|
+
lastRes = res;
|
|
315
|
+
totals.turns += res.turns;
|
|
316
|
+
totals.tokens += res.tokens;
|
|
317
|
+
totals.cost += res.cost ?? 0;
|
|
318
|
+
if (opts.killSwitch?.killed) return;
|
|
319
|
+
if (opts.nodeKills?.has(w.id)) {
|
|
320
|
+
await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns: totals.turns, tokens: totals.tokens, cost_usd_estimate: totals.cost });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (res.ok) {
|
|
324
|
+
finalError = undefined;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
finalError = res.error;
|
|
328
|
+
if (!res.error || !isRetryableError(res.error) || attempt === MAX_RETRY_ATTEMPTS - 1) {
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
await patch(w.id, { status_note: `retry ${attempt + 1}/${MAX_RETRY_ATTEMPTS - 1} after: ${res.error}` });
|
|
332
|
+
const delayMs = opts.retryDelayMs ? opts.retryDelayMs(attempt) : DEFAULT_RETRY_DELAY_MS(attempt);
|
|
333
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const res = lastRes;
|
|
337
|
+
if (!res) return;
|
|
338
|
+
|
|
255
339
|
if (opts.killSwitch?.killed) return;
|
|
256
340
|
if (opts.nodeKills?.has(w.id)) {
|
|
257
|
-
await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns:
|
|
341
|
+
await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns: totals.turns, tokens: totals.tokens, cost_usd_estimate: totals.cost });
|
|
258
342
|
return;
|
|
259
343
|
}
|
|
344
|
+
|
|
260
345
|
if (!res.ok) {
|
|
261
|
-
await patch(w.id, { status: "failed", ended_at: new Date().toISOString(), turns:
|
|
346
|
+
await patch(w.id, { status: "failed", ended_at: new Date().toISOString(), turns: totals.turns, tokens: totals.tokens, cost_usd_estimate: totals.cost });
|
|
347
|
+
recordCircuitError(w.id, finalError ?? res.error);
|
|
262
348
|
return;
|
|
263
349
|
}
|
|
350
|
+
|
|
264
351
|
if (w.worktree) {
|
|
265
352
|
try {
|
|
266
353
|
await commitWorktree({
|
|
@@ -274,9 +361,9 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
274
361
|
await patch(w.id, {
|
|
275
362
|
status: "failed",
|
|
276
363
|
ended_at: new Date().toISOString(),
|
|
277
|
-
turns:
|
|
278
|
-
tokens:
|
|
279
|
-
cost_usd_estimate:
|
|
364
|
+
turns: totals.turns,
|
|
365
|
+
tokens: totals.tokens,
|
|
366
|
+
cost_usd_estimate: totals.cost,
|
|
280
367
|
status_note: `commit failed: ${msg}`,
|
|
281
368
|
});
|
|
282
369
|
return;
|
|
@@ -293,9 +380,9 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
293
380
|
await patch(w.id, {
|
|
294
381
|
status: contract.ok ? "completed" : "contract_failed",
|
|
295
382
|
ended_at: new Date().toISOString(),
|
|
296
|
-
turns:
|
|
297
|
-
tokens:
|
|
298
|
-
cost_usd_estimate:
|
|
383
|
+
turns: totals.turns,
|
|
384
|
+
tokens: totals.tokens,
|
|
385
|
+
cost_usd_estimate: totals.cost,
|
|
299
386
|
contract_result: contract,
|
|
300
387
|
produced_outputs: contract.checks.filter((c) => c.ok || c.actualPath).map((c) => c.path),
|
|
301
388
|
status_note: contract.ok ? costNote : [contractFailureNote(contract.checks), costNote].filter(Boolean).join(" · "),
|
|
@@ -304,7 +391,7 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
304
391
|
const note = await opts.onNodeCompleted?.(w.id);
|
|
305
392
|
if (note) await patch(w.id, { status_note: note });
|
|
306
393
|
}
|
|
307
|
-
}).finally(() => running.delete(p));
|
|
394
|
+
})().finally(() => running.delete(p));
|
|
308
395
|
running.add(p);
|
|
309
396
|
}
|
|
310
397
|
if (running.size > 0) {
|
|
@@ -314,6 +401,33 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
314
401
|
return !!n && TERMINAL_NODE_STATUSES.has(n.status);
|
|
315
402
|
})) {
|
|
316
403
|
break;
|
|
404
|
+
} else {
|
|
405
|
+
// Defensive: nothing in flight, not all terminal, and nothing dispatchable
|
|
406
|
+
// would mean a tight infinite loop (busy-spin, event-loop starvation).
|
|
407
|
+
// Fail the stuck nodes instead of hanging.
|
|
408
|
+
const dispatchable = spec.workers.some((w) => {
|
|
409
|
+
const n = state.nodes[w.id];
|
|
410
|
+
if (!n || (n.status !== "pending" && n.status !== "ready")) return false;
|
|
411
|
+
if (opts.nodeKills?.has(w.id)) return true; // will be killed next pass
|
|
412
|
+
return w.depends_on.every((d) => state.nodes[d]?.status === "completed");
|
|
413
|
+
});
|
|
414
|
+
const blockingDepsFailed = spec.workers.some((w) => {
|
|
415
|
+
const n = state.nodes[w.id];
|
|
416
|
+
if (!n || (n.status !== "pending" && n.status !== "ready")) return false;
|
|
417
|
+
return w.depends_on.some((d) => FAILED.has(state.nodes[d]?.status ?? ""));
|
|
418
|
+
});
|
|
419
|
+
if (!dispatchable && !blockingDepsFailed) {
|
|
420
|
+
for (const w of spec.workers) {
|
|
421
|
+
const n = state.nodes[w.id];
|
|
422
|
+
if (!n || TERMINAL_NODE_STATUSES.has(n.status)) continue;
|
|
423
|
+
await patch(w.id, {
|
|
424
|
+
status: "failed",
|
|
425
|
+
ended_at: new Date().toISOString(),
|
|
426
|
+
status_note: `stuck in non-terminal status "${n.status}" with no dispatchable path; failed to avoid scheduler hang`,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
317
431
|
}
|
|
318
432
|
}
|
|
319
433
|
await Promise.allSettled([...running]);
|
package/src/state.ts
CHANGED
|
@@ -32,6 +32,8 @@ export async function readState(fleetRoot: string): Promise<FleetState> {
|
|
|
32
32
|
lgtm_streak: parsed.lgtm_streak ?? 0,
|
|
33
33
|
paused: parsed.paused ?? false,
|
|
34
34
|
iterations: parsed.iterations ?? [],
|
|
35
|
+
pid: parsed.pid,
|
|
36
|
+
heartbeat_at: parsed.heartbeat_at,
|
|
35
37
|
};
|
|
36
38
|
}
|
|
37
39
|
|
package/src/tools.ts
CHANGED
|
@@ -58,6 +58,8 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
58
58
|
model: Type.Optional(Type.String({ description: "Fleet-wide default model" })),
|
|
59
59
|
effort: Type.Optional(EffortSchema),
|
|
60
60
|
warn_cost_usd: Type.Optional(Type.Number()),
|
|
61
|
+
max_cost_usd: Type.Optional(Type.Number()),
|
|
62
|
+
worker_extensions: Type.Optional(Type.Array(Type.String())),
|
|
61
63
|
loop: Type.Optional(Type.Object({
|
|
62
64
|
gate: Type.Union([Type.Literal("reviewer"), Type.Literal("none")]),
|
|
63
65
|
max_iterations: Type.Number(),
|
|
@@ -335,11 +337,11 @@ export function registerFleetTools(pi: ExtensionAPI): void {
|
|
|
335
337
|
pi.registerTool({
|
|
336
338
|
name: "fleet_edit",
|
|
337
339
|
label: "Fleet Edit",
|
|
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).",
|
|
340
|
+
description: "Edit the active fleet: a pending or relaunchable node's model, effort, or task — or fleet config (max_concurrent, warn_cost_usd, max_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).",
|
|
339
341
|
promptSnippet: "Edit a pending fleet node or fleet config.",
|
|
340
342
|
parameters: Type.Object({
|
|
341
343
|
node_id: Type.Optional(Type.String({ description: "Worker id to edit; omit for fleet config edits" })),
|
|
342
|
-
key: Type.String({ description: "Node keys: model, effort, task. Config keys: max_concurrent, warn_cost_usd, model, effort" }),
|
|
344
|
+
key: Type.String({ description: "Node keys: model, effort, task. Config keys: max_concurrent, warn_cost_usd, max_cost_usd, model, effort" }),
|
|
343
345
|
value: Type.String({ description: "New value" }),
|
|
344
346
|
}),
|
|
345
347
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
package/src/types.ts
CHANGED
|
@@ -44,6 +44,11 @@ export interface FleetConfig {
|
|
|
44
44
|
model?: string;
|
|
45
45
|
effort?: ThinkingLevelName;
|
|
46
46
|
warn_cost_usd?: number;
|
|
47
|
+
/** hard cap — fleet is killed when total estimated cost reaches it */
|
|
48
|
+
max_cost_usd?: number;
|
|
49
|
+
/** extension path fragments to load inside worker sessions (default: none).
|
|
50
|
+
* Needed for extension-provided model providers, e.g. ["opencode-pi"]. */
|
|
51
|
+
worker_extensions?: string[];
|
|
47
52
|
loop?: LoopConfig;
|
|
48
53
|
}
|
|
49
54
|
|
|
@@ -112,6 +117,10 @@ export interface FleetState {
|
|
|
112
117
|
lgtm_streak: number;
|
|
113
118
|
paused: boolean;
|
|
114
119
|
iterations: IterationSnapshot[];
|
|
120
|
+
/** liveness signal written by the running controller */
|
|
121
|
+
pid?: number;
|
|
122
|
+
/** ISO timestamp liveness signal written by the running controller */
|
|
123
|
+
heartbeat_at?: string;
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
export const WORKER_TYPE_TOOLS: Record<WorkerType, string[]> = {
|