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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-fleet",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "DAG-of-agents fleets for pi — parallel workers with contracts, reviewer-gated iteration loops, and live progress",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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.Left} />
183
- <Handle type="target" position={Position.Bottom} id="loopIn" />
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.Right} />
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(true);
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">{event.text}</div>
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: true,
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, prepareRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
3
- import { persistFleetJson, writeWorkerPrompts } from "./fleet-store.js";
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, resolveModelReference } from "./model-resolution.js";
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 { resetForRelaunch, writeState } from "./state.js";
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
- if (model) {
212
- const resolved = resolveModelReference(ctx.modelRegistry, model);
213
- if (!resolved.ok) {
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
- return s.size > 0
32
- ? { ...base, ok: true }
33
- : fail("empty file");
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
- return { ok: false, message: `node "${nodeId}" is ${node.status}; only pending, failed, contract_failed, or killed nodes can be edited` };
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);
@@ -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
- const state = await readState(fleetRoot);
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 ? undefined : contractFailureNote(contract.checks),
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 runPass();
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 runPass();
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 resetForRelaunch(state: FleetState, spec: FleetSpec, nodeId: string): FleetState {
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 downstream = new Set<string>();
135
- const walk = (id: string) => {
136
- for (const d of dependents[id] ?? []) {
137
- if (!downstream.has(d)) {
138
- downstream.add(d);
139
- walk(d);
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
- walk(nodeId);
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
- nodes[nodeId] = fresh;
148
- for (const id of downstream) {
149
- if (state.nodes[id].status === "blocked") {
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, prepareRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
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, persistFleetJson, writePlanFiles, writeWorkerPrompts } from "./fleet-store.js";
10
+ import { ensureFleetGitignore, fleetRootFor, isInsideGitRepo, writePlanFiles, writeWorkerPrompts } from "./fleet-store.js";
11
11
  import { recoverLatestFleet } from "./fleet-recovery.js";
12
- import { listModelRefs, resolveModelReference, validateFleetModels } from "./model-resolution.js";
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, resetForRelaunch, writeState } from "./state.js";
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 worker = fleet.spec.workers.find((w) => w.id === params.node_id);
259
- if (!worker) return textResult(`unknown node "${params.node_id}"`);
260
- const node = fleet.state.nodes[params.node_id];
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, completed, or blocked nodes are refused; failed, contract_failed, and killed nodes can be edited so relaunch uses the updated task.",
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" })),