agent-horde 0.1.6 → 0.2.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.
@@ -2,11 +2,16 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { oneLine } from "../format.js";
4
4
  import { childEnv, execPi, getPiInvocation } from "../member.js";
5
- import type { HordeRun, HordeState } from "./types.js";
5
+ import { parsePlannerOutput } from "./swarm.js";
6
+ import { hordeLabel } from "./types.js";
7
+ import type { HordeRun, HordeState, Swarm } from "./types.js";
6
8
 
7
9
  /** How long the post-mortem agent may run. */
8
10
  const POSTMORTEM_TIMEOUT_MS = Number(process.env.PI_HORDE_POSTMORTEM_MS) || 300_000;
9
11
 
12
+ /** How long the planner that partitions a task between hordes may run. */
13
+ const PLANNER_TIMEOUT_MS = Number(process.env.PI_HORDE_PLANNER_MS) || 300_000;
14
+
10
15
  const POSTMORTEM_SYSTEM_PROMPT =
11
16
  "You are a post-mortem reviewer for a multi-agent horde run. You have read-only tools. " +
12
17
  "Critique the run and the orchestrating extension, not just the underlying project. Be " +
@@ -17,43 +22,63 @@ const DELIVERABLE_SYSTEM_PROMPT =
17
22
  "You are the synthesis member of a horde. You have read-only tools and must produce one " +
18
23
  "deliverable that answers the task the horde was given, not a summary of the process.";
19
24
 
20
- type HelperLabel = "post-mortem" | "deliverable";
25
+ const SWARM_DELIVERABLE_SYSTEM_PROMPT =
26
+ "You are the synthesis member of a swarm of hordes. You have read-only tools and must merge the " +
27
+ "hordes' deliverables into one deliverable that answers the task, keeping every claim attributed " +
28
+ "to the horde that made it and to the acceptance status that horde recorded.";
29
+
30
+ const PLANNER_SYSTEM_PROMPT =
31
+ "You are the planner for a swarm of agent hordes. You have read-only tools. You partition one task " +
32
+ "into disjoint workstreams that independent teams can work in parallel without overlapping. You " +
33
+ "output only the JSON you are asked for.";
34
+
35
+ type HelperLabel = "post-mortem" | "deliverable" | "swarm deliverable" | "planner";
21
36
 
22
37
  /** Run a one-shot pi helper and return its output, or a one-line failure text. */
23
38
  async function runHelper(
24
39
  label: HelperLabel,
25
40
  invocation: { command: string; args: string[] },
26
- cwd: string,
27
- signal: AbortSignal,
28
- ): Promise<string> {
41
+ options: { cwd: string; signal: AbortSignal; timeoutMs?: number | undefined },
42
+ ): Promise<{ text: string; failed: boolean }> {
43
+ const timeoutMs = options.timeoutMs ?? POSTMORTEM_TIMEOUT_MS;
29
44
  const result = await execPi(invocation, {
30
- cwd,
45
+ cwd: options.cwd,
31
46
  env: childEnv(),
32
- timeoutMs: POSTMORTEM_TIMEOUT_MS,
33
- signal,
47
+ timeoutMs,
48
+ signal: options.signal,
34
49
  });
35
50
  const text = result.stdout.trim();
36
51
  if (result.timedOut) {
37
- return `${label} agent failed: timed out after ${Math.round(POSTMORTEM_TIMEOUT_MS / 1000)}s`;
52
+ return { text: `${label} agent failed: timed out after ${Math.round(timeoutMs / 1000)}s`, failed: true };
38
53
  }
39
54
  if (result.code !== 0 || text === "") {
40
55
  const detail = oneLine(result.stderr.trim() || "no output", 200);
41
- return `${label} agent failed (code ${result.code ?? "signal"}): ${detail}`;
56
+ return { text: `${label} agent failed (code ${result.code ?? "signal"}): ${detail}`, failed: true };
42
57
  }
43
- return text;
58
+ return { text, failed: false };
59
+ }
60
+
61
+ /** Read-only pi invocation with its own session directory. */
62
+ function readOnlyInvocation(
63
+ sessions: string,
64
+ model: string | undefined,
65
+ systemPrompt: string,
66
+ task: string,
67
+ ): { command: string; args: string[] } {
68
+ fs.mkdirSync(sessions, { recursive: true });
69
+ const args = ["-p", "--tools", "read,grep,find,ls", "--session-dir", sessions];
70
+ if (model) args.push("--model", model);
71
+ args.push("--approve", "--append-system-prompt", systemPrompt, "--", task);
72
+ return getPiInvocation(args);
44
73
  }
45
74
 
46
- /** Read-only pi invocation for a helper, with its own session directory under the run. */
75
+ /** A run helper's invocation: ledger paths are swapped for the frozen snapshot when one exists. */
47
76
  function helperInvocation(
48
77
  run: HordeRun,
49
78
  sessionDir: string,
50
79
  systemPrompt: string,
51
80
  task: string,
52
81
  ): { command: string; args: string[] } {
53
- const sessions = path.join(run.runDir, sessionDir);
54
- fs.mkdirSync(sessions, { recursive: true });
55
- const args = ["-p", "--tools", "read,grep,find,ls", "--session-dir", sessions];
56
- if (run.model) args.push("--model", run.model);
57
82
  for (const name of ["BOARD", "FINDINGS", "IDEAS", "FACTS", "CLAIMS", "RESOURCES", "CONCERNS"]) {
58
83
  const frozen = path.join(run.runDir, "snapshot", `${name}.md`);
59
84
  if (fs.existsSync(frozen)) task = task.split(path.join(run.hordeDir, `${name}.md`)).join(frozen);
@@ -62,8 +87,7 @@ function helperInvocation(
62
87
  "Use the frozen acceptance snapshot. Exclude unreviewed, disputed or stale artifacts from recommended runnable outputs. " +
63
88
  "acceptedDocuments establishes document-criteria review only; recommend runnable outputs only from acceptedRunnableArtifacts. Preserve recorded peer confirmation even if acceptance remains incomplete. " +
64
89
  "Disclose partial checks and unresolved coverage. Legacy verified labels alone do not establish a full reproduction.";
65
- args.push("--approve", "--append-system-prompt", systemPrompt, "--", task);
66
- return getPiInvocation(args);
90
+ return readOnlyInvocation(path.join(run.runDir, sessionDir), run.model, systemPrompt, task);
67
91
  }
68
92
 
69
93
  function postmortemTask(run: HordeRun): string {
@@ -125,6 +149,7 @@ function deliverableTask(run: HordeRun): string {
125
149
  ``,
126
150
  run.prompt,
127
151
  ``,
152
+ ...(run.slice ? [`This horde owned one workstream of that task: ${run.slice}`, `Deliver that workstream's part of the answer.`, ``] : []),
128
153
  `Read these with the read tool:`,
129
154
  `- ${path.join(run.runDir, "REPORT.md")}`,
130
155
  `- ${path.join(run.runDir, "SUMMARY.md")}`,
@@ -170,7 +195,7 @@ export async function runPostmortem(state: HordeState, run: HordeRun): Promise<s
170
195
  const controller = new AbortController();
171
196
  run.postmortemAbort = controller;
172
197
  try {
173
- return await runHelper("post-mortem", invocation, run.cwd, controller.signal);
198
+ return (await runHelper("post-mortem", invocation, { cwd: run.cwd, signal: controller.signal })).text;
174
199
  } finally {
175
200
  run.postmortemAbort = undefined;
176
201
  }
@@ -188,8 +213,104 @@ export async function runDeliverable(state: HordeState, run: HordeRun): Promise<
188
213
  const controller = new AbortController();
189
214
  run.deliverableAbort = controller;
190
215
  try {
191
- return await runHelper("deliverable", invocation, run.cwd, controller.signal);
216
+ return (await runHelper("deliverable", invocation, { cwd: run.cwd, signal: controller.signal })).text;
192
217
  } finally {
193
218
  run.deliverableAbort = undefined;
194
219
  }
195
220
  }
221
+
222
+ function swarmDeliverableTask(swarm: Swarm): string {
223
+ const dir = swarm.dir ?? "";
224
+ return [
225
+ `Produce the single merged deliverable for swarm ${swarm.id}: ${swarm.hordes} hordes worked disjoint`,
226
+ `workstreams of one task in parallel, and each wrote its own deliverable.`,
227
+ ``,
228
+ `The task the swarm was given:`,
229
+ ``,
230
+ swarm.prompt,
231
+ ``,
232
+ `The partition:`,
233
+ ...(swarm.partition ?? []).map((slice, index) => `- ${hordeLabel(index + 1)}: ${slice}`),
234
+ ``,
235
+ `Read these with the read tool:`,
236
+ `- ${path.join(dir, "SWARM.md")}`,
237
+ ...swarm.runs.flatMap((run) => [
238
+ `- ${path.join(run.runDir, "DELIVERABLE.md")} (${hordeLabel(run.horde)}; absent when that horde was hard-stopped)`,
239
+ `- ${path.join(run.runDir, "REPORT.md")} (${hordeLabel(run.horde)})`,
240
+ `- ${path.join(run.runDir, "ACCEPTANCE.md")} (${hordeLabel(run.horde)}; when present)`,
241
+ `- ${path.join(run.runDir, "ANSWER.md")} (${hordeLabel(run.horde)}; when a solve was proposed)`,
242
+ ]),
243
+ ``,
244
+ `Merge the hordes' deliverables into one artifact that answers the whole task in the form the task`,
245
+ `implies. Keep every claim attributed to its horde (${swarm.runs.map((run) => hordeLabel(run.horde)).join(", ")})`,
246
+ `and keep the acceptance label its horde recorded; do not upgrade a claim's support by combining`,
247
+ `hordes, since no horde reviewed another's work. Where hordes overlap or contradict each other, say`,
248
+ `so and keep both attributions. Name any workstream whose horde produced no deliverable. Do not`,
249
+ `describe the process. Output only markdown.`,
250
+ ].join("\n");
251
+ }
252
+
253
+ /** Spawn the synthesis member that merges the hordes' deliverables into the swarm's. */
254
+ export async function runSwarmDeliverable(state: HordeState, swarm: Swarm): Promise<string | undefined> {
255
+ if (state.disposed || !swarm.dir) return undefined;
256
+ const invocation = readOnlyInvocation(
257
+ path.join(swarm.dir, "deliverable-sessions"),
258
+ swarm.runs[0]?.model,
259
+ SWARM_DELIVERABLE_SYSTEM_PROMPT,
260
+ swarmDeliverableTask(swarm),
261
+ );
262
+ const controller = new AbortController();
263
+ swarm.deliverableAbort = controller;
264
+ try {
265
+ return (await runHelper("swarm deliverable", invocation, { cwd: swarm.cwd, signal: controller.signal })).text;
266
+ } finally {
267
+ swarm.deliverableAbort = undefined;
268
+ }
269
+ }
270
+
271
+ function plannerTask(prompt: string, hordes: number): string {
272
+ return [
273
+ `Partition the task below into exactly ${hordes} disjoint workstreams for ${hordes} independent teams`,
274
+ `that will work in parallel in this directory without talking to each other. Each workstream must`,
275
+ `be self-contained, must not overlap another, and should take about the same effort; together they`,
276
+ `must cover the whole task. Give each workstream one acceptance criterion a team can check on its`,
277
+ `own. Read the working directory as needed to split the task along real seams (modules, inputs,`,
278
+ `chapters, question groups); do not modify anything.`,
279
+ ``,
280
+ `Output ONLY a JSON array of exactly ${hordes} objects, nothing before or after it:`,
281
+ `[{"workstream": "<what this team does>", "acceptance": "<how the team knows it is done>"}, ...]`,
282
+ ``,
283
+ `Task:`,
284
+ ``,
285
+ prompt,
286
+ ].join("\n");
287
+ }
288
+
289
+ /**
290
+ * Partition a plain prompt between the hordes with a read-only planner. Returns the H slices,
291
+ * or the reason the launch must stop: a planner that cannot produce H distinct workstreams
292
+ * would have every horde doing the same work.
293
+ */
294
+ export async function runPlanner(
295
+ state: HordeState,
296
+ input: { dir: string; cwd: string; model: string | undefined; prompt: string; hordes: number },
297
+ ): Promise<{ slices: string[] } | { error: string }> {
298
+ if (state.disposed) return { error: "session shut down" };
299
+ const invocation = readOnlyInvocation(
300
+ path.join(input.dir, "planner-sessions"),
301
+ input.model,
302
+ PLANNER_SYSTEM_PROMPT,
303
+ plannerTask(input.prompt, input.hordes),
304
+ );
305
+ const controller = new AbortController();
306
+ state.plannerAbort = controller;
307
+ try {
308
+ const result = await runHelper("planner", invocation, {
309
+ cwd: input.cwd, signal: controller.signal, timeoutMs: PLANNER_TIMEOUT_MS,
310
+ });
311
+ if (result.failed) return { error: result.text };
312
+ return parsePlannerOutput(result.text, input.hordes);
313
+ } finally {
314
+ state.plannerAbort = undefined;
315
+ }
316
+ }
package/src/run/types.ts CHANGED
@@ -11,6 +11,10 @@ export interface HordeRun extends MemberRunInfo {
11
11
  controllers: Map<string, AbortController>;
12
12
  procs: Map<string, ChildProcess>;
13
13
  cwd: string;
14
+ /** 1-based position in the swarm; the horde is addressed as `h<N>`. */
15
+ horde: number;
16
+ /** The horde's workstream when the swarm partitioned the task; every member sees it. */
17
+ slice?: string | undefined;
14
18
  /** Highest fact id at run start, to separate this run's facts from carried context. */
15
19
  startFactId: number;
16
20
  /** Highest idea id at run start; ideas created above it are this run's coverage. */
@@ -34,6 +38,8 @@ export interface HordeRun extends MemberRunInfo {
34
38
  repairTokens?: number;
35
39
  /** Members whose queued repair work was skipped because the budget could not fund an attempt. */
36
40
  repairSkipped?: string[];
41
+ /** Members whose remaining repair threads their own completed attempt could not move. */
42
+ repairStalled?: string[];
37
43
  /** True when a user command or session shutdown ended the run. */
38
44
  userStop?: boolean | undefined;
39
45
  /** Human-readable stop reason for the report, e.g. "stopped by user". */
@@ -47,6 +53,12 @@ export interface HordeRun extends MemberRunInfo {
47
53
  deliverableAbort?: AbortController | undefined;
48
54
  /** Steps still running after members finished, e.g. ["deliverable", "post-mortem"]. */
49
55
  finalizing?: string[] | undefined;
56
+ /** The run's outcome label once REPORT.md is written, e.g. "solved" or "stopped-clean". */
57
+ outcome?: string | undefined;
58
+ /** True once members exited and REPORT.md is written; the swarm slot frees when every run is. */
59
+ reported?: boolean | undefined;
60
+ /** True once finalize completed, synthesis included. */
61
+ finalized?: boolean | undefined;
50
62
  /** Workstream partition when the run was started with `plan`. */
51
63
  plan?: string[] | undefined;
52
64
  /** Member carrying the verifier role, when one was assigned. */
@@ -79,6 +91,38 @@ export interface HordeRun extends MemberRunInfo {
79
91
  solvedCoverageNotice?: boolean | undefined;
80
92
  postmortemAbort?: AbortController | undefined;
81
93
  ui?: ExtensionUIContext;
94
+ }
95
+
96
+ /**
97
+ * H concurrent hordes on one task. A plain `12` is a swarm of one whose run lives in
98
+ * `.horde/runs/`; `3/12` gives each horde its own ledgers under `.horde/swarms/<id>/h<N>/`.
99
+ */
100
+ export interface Swarm {
101
+ id: string;
102
+ /** `.horde/swarms/<id>`; undefined for a swarm of one. */
103
+ dir: string | undefined;
104
+ /** The project's `.horde` directory. */
105
+ hordeDir: string;
106
+ cwd: string;
107
+ prompt: string;
108
+ hordes: number;
109
+ /** Members per horde. */
110
+ count: number;
111
+ startedAt: number;
112
+ /** One run per horde, `h1` first. */
113
+ runs: HordeRun[];
114
+ /** The `plan` layers as given, before they were dealt to the hordes. */
115
+ plan?: string[] | undefined;
116
+ /** Per-horde workstream, indexed by horde - 1; absent for a swarm of one. */
117
+ partition?: string[] | undefined;
118
+ untilStop?: boolean | undefined;
119
+ dashboardUrl?: string | undefined;
120
+ /** Generated merged deliverable across the hordes' deliverables. */
121
+ deliverable?: string | undefined;
122
+ deliverableAbort?: AbortController | undefined;
123
+ /** The swarm step still running after every horde finalized, e.g. ["merge"]. */
124
+ finalizing?: string[] | undefined;
125
+ ui?: ExtensionUIContext;
82
126
  tui?: TUI;
83
127
  timer?: ReturnType<typeof setInterval> | undefined;
84
128
  }
@@ -96,13 +140,15 @@ export interface ReportDetails {
96
140
  /** Parent-side extension state shared by the command, run lifecycle, and shutdown. */
97
141
  export interface HordeState {
98
142
  pi: ExtensionAPI;
99
- /** The run whose members are live; at most one at a time. */
100
- active: HordeRun | undefined;
101
- /** A run whose members finished but whose synthesis helpers are still running. */
102
- finalizing: HordeRun | undefined;
143
+ /** The swarm whose members are live; at most one at a time. */
144
+ active: Swarm | undefined;
145
+ /** A swarm whose members finished but whose synthesis helpers are still running. */
146
+ finalizing: Swarm | undefined;
103
147
  dashboard: DashboardServer | undefined;
104
- /** Set synchronously at startRun entry so a same-tick second start is refused. */
148
+ /** Set synchronously at startSwarm entry so a same-tick second start is refused. */
105
149
  starting: boolean;
150
+ /** The planner partitioning a task between hordes, so shutdown can end it. */
151
+ plannerAbort?: AbortController | undefined;
106
152
  /** Set at session shutdown; lifecycle steps check it and stop early. */
107
153
  disposed: boolean;
108
154
  }
@@ -117,3 +163,18 @@ export function createHordeState(pi: ExtensionAPI): HordeState {
117
163
  disposed: false,
118
164
  };
119
165
  }
166
+
167
+ /** `h2` for the second horde; the label every notification, target, and file uses. */
168
+ export function hordeLabel(horde: number): string {
169
+ return `h${horde}`;
170
+ }
171
+
172
+ /** Runs of the swarm whose members are not all finished and reported. */
173
+ export function activeRuns(swarm: Swarm): HordeRun[] {
174
+ return swarm.runs.filter((run) => run.reported !== true);
175
+ }
176
+
177
+ /** Runs of the swarm whose members finished but whose synthesis is still running. */
178
+ export function finalizingRuns(swarm: Swarm): HordeRun[] {
179
+ return swarm.runs.filter((run) => run.reported === true && run.finalized !== true);
180
+ }
package/src/run/ui.ts CHANGED
@@ -3,7 +3,10 @@ import { Text } from "@earendil-works/pi-tui";
3
3
  import { formatElapsed, formatTokens } from "../format.js";
4
4
  import { statusCounts } from "../prompt.js";
5
5
  import { HordeWidget } from "../widget.js";
6
- import type { HordeRun, ReportDetails } from "./types.js";
6
+ import { SWARM_REPORT_TYPE } from "./swarm-report.js";
7
+ import type { SwarmReportDetails } from "./swarm-report.js";
8
+ import { hordeLabel } from "./types.js";
9
+ import type { HordeRun, ReportDetails, Swarm } from "./types.js";
7
10
 
8
11
  const WIDGET_KEY = "agent-horde";
9
12
 
@@ -11,7 +14,26 @@ export const REPORT_TYPE = "agent-horde-report";
11
14
 
12
15
  const RENDER_INTERVAL_MS = 100;
13
16
 
17
+ function registerSwarmReportRenderer(pi: ExtensionAPI): void {
18
+ pi.registerMessageRenderer<SwarmReportDetails>(SWARM_REPORT_TYPE, (message, { outputPad }, theme) => {
19
+ const d = message.details;
20
+ if (!d) return undefined;
21
+ const title = `swarm ${d.swarmId} finished: ${d.hordes.length} hordes, ${formatElapsed(d.elapsedMs)}`;
22
+ const lines = [theme.bold(theme.fg("accent", title))];
23
+ for (const h of d.hordes) {
24
+ lines.push(theme.fg("muted", ` ${h.label.padEnd(3)} ${h.outcome.padEnd(18)} ${h.done} done ${h.failed} failed ` +
25
+ `${h.stopped} stopped t${h.turns} ${formatTokens(h.tokens)} tok $${h.cost.toFixed(2)} ${h.findings} findings`));
26
+ }
27
+ if (d.deliverable) {
28
+ lines.push("", theme.bold(theme.fg("accent", `swarm ${d.swarmId} deliverable`)), "");
29
+ lines.push(d.deliverable.trimEnd());
30
+ }
31
+ return new Text(lines.join("\n"), outputPad, 0);
32
+ });
33
+ }
34
+
14
35
  export function registerReportRenderer(pi: ExtensionAPI): void {
36
+ registerSwarmReportRenderer(pi);
15
37
  pi.registerMessageRenderer<ReportDetails>(REPORT_TYPE, (message, { outputPad }, theme) => {
16
38
  const d = message.details;
17
39
  if (!d) return undefined;
@@ -38,50 +60,73 @@ export function registerReportRenderer(pi: ExtensionAPI): void {
38
60
  });
39
61
  }
40
62
 
41
- /** Which run owns the widget on a shared UI context; a later run must not be clobbered. */
42
- const widgetOwners = new WeakMap<ExtensionUIContext, HordeRun>();
63
+ /** Which swarm owns the widget on a shared UI context; a later swarm must not be clobbered. */
64
+ const widgetOwners = new WeakMap<ExtensionUIContext, Swarm>();
43
65
 
44
- export function refreshRun(run: HordeRun): void {
45
- if (!run.ui || widgetOwners.get(run.ui) !== run) return;
46
- if (run.finalizing && run.finalizing.length > 0) {
47
- run.ui.setStatus(WIDGET_KEY, `horde ${run.id} synthesizing: ${run.finalizing.join(", ")}`);
48
- run.tui?.requestRender();
49
- return;
50
- }
66
+ /** The swarm each live run belongs to, so a per-run refresh redraws the whole widget. */
67
+ const swarmOf = new WeakMap<HordeRun, Swarm>();
68
+
69
+ /** One run's share of the status line: `h2 11/12 running, 1 paused, clean stop in 40s`. */
70
+ function runStatus(run: HordeRun, labelled: boolean): string {
51
71
  const c = statusCounts(run.members);
52
72
  const paused = run.members.filter((m) => m.paused).length;
53
- let text = `horde ${c.running + c.starting}/${run.count} running`;
73
+ let text = `${labelled ? `${hordeLabel(run.horde)} ` : ""}${c.running + c.starting}/${run.count} running`;
54
74
  if (paused > 0) text += `, ${paused} paused`;
55
75
  if (run.cleanStopUntil !== undefined) {
56
76
  const left = Math.max(0, Math.ceil((run.cleanStopUntil - Date.now()) / 1000));
57
77
  text += `, clean stop in ${left}s`;
58
78
  }
59
- run.ui.setStatus(WIDGET_KEY, text);
60
- run.tui?.requestRender();
79
+ if (run.finalizing && run.finalizing.length > 0) text += `, synthesizing ${run.finalizing.join(", ")}`;
80
+ return text;
81
+ }
82
+
83
+ export function refreshSwarm(swarm: Swarm): void {
84
+ if (!swarm.ui || widgetOwners.get(swarm.ui) !== swarm) return;
85
+ const single = swarm.runs.length === 1;
86
+ let text: string;
87
+ if (swarm.finalizing && swarm.finalizing.length > 0) {
88
+ text = `swarm ${swarm.id} synthesizing: ${swarm.finalizing.join(", ")}`;
89
+ } else if (single && swarm.runs[0]!.finalizing && swarm.runs[0]!.finalizing.length > 0) {
90
+ text = `horde ${swarm.id} synthesizing: ${swarm.runs[0]!.finalizing.join(", ")}`;
91
+ } else {
92
+ const parts = swarm.runs.map((run) => runStatus(run, !single));
93
+ text = single ? `horde ${parts[0]}` : `swarm ${parts.join("; ")}`;
94
+ }
95
+ swarm.ui.setStatus(WIDGET_KEY, text);
96
+ swarm.tui?.requestRender();
61
97
  }
62
98
 
63
- export function mountWidget(run: HordeRun, ui: ExtensionUIContext): void {
64
- run.ui = ui;
65
- widgetOwners.set(ui, run);
99
+ export function refreshRun(run: HordeRun): void {
100
+ const swarm = swarmOf.get(run);
101
+ if (swarm) refreshSwarm(swarm);
102
+ }
103
+
104
+ export function mountWidget(swarm: Swarm, ui: ExtensionUIContext): void {
105
+ swarm.ui = ui;
106
+ for (const run of swarm.runs) {
107
+ run.ui = ui;
108
+ swarmOf.set(run, swarm);
109
+ }
110
+ widgetOwners.set(ui, swarm);
66
111
  ui.setWidget(
67
112
  WIDGET_KEY,
68
113
  (tui, theme) => {
69
- run.tui = tui;
70
- return new HordeWidget(run, theme);
114
+ swarm.tui = tui;
115
+ return new HordeWidget(swarm, theme);
71
116
  },
72
117
  { placement: "aboveEditor" },
73
118
  );
74
- run.timer = setInterval(() => refreshRun(run), RENDER_INTERVAL_MS);
75
- run.timer.unref();
119
+ swarm.timer = setInterval(() => refreshSwarm(swarm), RENDER_INTERVAL_MS);
120
+ swarm.timer.unref();
76
121
  }
77
122
 
78
- export function unmountWidget(run: HordeRun): void {
79
- if (run.timer) clearInterval(run.timer);
80
- run.timer = undefined;
81
- if (!run.ui || widgetOwners.get(run.ui) !== run) return;
82
- widgetOwners.delete(run.ui);
83
- run.ui.setWidget(WIDGET_KEY, undefined);
84
- run.ui.setStatus(WIDGET_KEY, undefined);
123
+ export function unmountWidget(swarm: Swarm): void {
124
+ if (swarm.timer) clearInterval(swarm.timer);
125
+ swarm.timer = undefined;
126
+ if (!swarm.ui || widgetOwners.get(swarm.ui) !== swarm) return;
127
+ widgetOwners.delete(swarm.ui);
128
+ swarm.ui.setWidget(WIDGET_KEY, undefined);
129
+ swarm.ui.setStatus(WIDGET_KEY, undefined);
85
130
  }
86
131
 
87
132
  export function reportDetails(run: HordeRun, now: number): ReportDetails {
@@ -114,7 +159,7 @@ export function safeNotifyUi(
114
159
  }
115
160
  }
116
161
 
117
- export function safeNotify(run: HordeRun, message: string, level: "info" | "warning" | "error"): void {
162
+ export function safeNotify(run: Pick<HordeRun, "ui">, message: string, level: "info" | "warning" | "error"): void {
118
163
  try {
119
164
  run.ui?.notify(message, level);
120
165
  } catch {
package/src/run-graph.ts CHANGED
@@ -117,14 +117,14 @@ export function entityHref(runId: string, id: string): string | undefined {
117
117
  if (!match) return undefined;
118
118
  const letter = match[1] ?? "";
119
119
  const number = match[2] ?? "";
120
- const run = encodeURIComponent(runId);
120
+ const base = `/runs/${encodeURIComponent(runId)}`;
121
121
  switch (letter) {
122
- case "I": return `/ideas#idea-${number}`;
123
- case "F": return `/findings?run=${run}#F${number}`;
124
- case "T": return `/facts?run=${run}#T${number}`;
125
- case "C": return `/claims#C${number}`;
126
- case "R": return `/resources#R${number}`;
127
- case "A": case "V": case "Q": return `/runs/${run}/collaboration#${id}`;
122
+ case "I": return `${base}/ideas#idea-${number}`;
123
+ case "F": return `${base}/findings#F${number}`;
124
+ case "T": return `${base}/facts#T${number}`;
125
+ case "C": return `${base}/claims#C${number}`;
126
+ case "R": return `${base}/resources#R${number}`;
127
+ case "A": case "V": case "Q": return `${base}/collaboration#${id}`;
128
128
  case "E": return eventHref(runId, Number(number));
129
129
  default: return undefined;
130
130
  }
@@ -0,0 +1,127 @@
1
+ import { oneLine } from "./format.js";
2
+ import type { HordeEvent } from "./ledger/events.js";
3
+ import { LIFECYCLE_ATTEMPT, eventHref } from "./run-graph.js";
4
+ import type { GraphEdge, GraphLane, GraphNode, RunGraph } from "./run-graph.js";
5
+
6
+ /** One horde's events and where its run page is. */
7
+ export interface SwarmGraphHorde {
8
+ label: string;
9
+ runId: string;
10
+ events: HordeEvent[];
11
+ status?: string | undefined;
12
+ }
13
+
14
+ export interface SwarmGraphInput {
15
+ swarmId: string;
16
+ startedAt: number;
17
+ hordes: SwarmGraphHorde[];
18
+ /** The swarm's merge step, once it started. */
19
+ merge?: { startedAt: number; endedAt?: number | undefined; written?: boolean | undefined } | undefined;
20
+ /** Graph end for a live swarm (epoch ms); a finished swarm ends at its last event. */
21
+ now?: number | undefined;
22
+ }
23
+
24
+ const SWARM_LANE = "swarm";
25
+
26
+ /** The parent lifecycle actions that mark a horde's progress; member-level churn stays on the run graph. */
27
+ const PHASE_ACTIONS = new Set([
28
+ "run-start", "phase-start", "phase-end", "stop-clean", "stop-hard", "solved-validated", "solved-banked",
29
+ "solved-contested", "acceptance", "run-end",
30
+ ]);
31
+
32
+ function str(value: unknown): string | undefined {
33
+ return typeof value === "string" ? value : undefined;
34
+ }
35
+
36
+ function labelOf(event: HordeEvent): [string, string] {
37
+ const p = event.payload;
38
+ switch (event.action) {
39
+ case "run-start": return ["start", `${String(p.count ?? "?")} members${str(p.slice) ? `; workstream: ${oneLine(str(p.slice) ?? "", 160)}` : ""}`];
40
+ case "phase-start": return [`${str(p.phase) ?? "phase"} start`, `${str(p.phase) ?? "phase"} started`];
41
+ case "phase-end": return [`${str(p.phase) ?? "phase"} end`, `${str(p.phase) ?? "phase"} ended`];
42
+ case "stop-clean": return [`stop-clean (${str(p.label) ?? "stop-clean"})`, "clean stop requested"];
43
+ case "stop-hard": return [`stop-hard (${str(p.by) ?? "user"})`, "members terminated"];
44
+ case "solved-validated": return ["solved validated", str(p.validator) ? `by ${str(p.validator)}` : "without a separate validator"];
45
+ case "solved-banked": return [`solved banked (round ${String(p.round ?? "?")})`, "the run continues until stopped"];
46
+ case "solved-contested": return ["solved contested", `by ${str(p.member) ?? ""}`];
47
+ case "acceptance": return [`acceptance ${str(p.status) ?? ""}`.trim(), `${String(p.issues ?? 0)} issues`];
48
+ case "run-end": return ["end", `outcome ${str(p.outcome) ?? "unknown"}`];
49
+ default: return [event.action, ""];
50
+ }
51
+ }
52
+
53
+ function hordeNodes(horde: SwarmGraphHorde): GraphNode[] {
54
+ return horde.events
55
+ .filter((event) => event.attempt === LIFECYCLE_ATTEMPT && event.outcome === "applied" && PHASE_ACTIONS.has(event.action))
56
+ .map((event) => {
57
+ const [label, title] = labelOf(event);
58
+ const at = Date.parse(event.at);
59
+ return {
60
+ id: `${horde.label}:${event.eventId}`,
61
+ kind: "lifecycle" as const,
62
+ action: event.action,
63
+ label,
64
+ title: `${horde.label} ${event.eventId} ${title}`.trim(),
65
+ lane: horde.label,
66
+ at: Number.isFinite(at) ? at : 0,
67
+ eventId: event.eventId,
68
+ href: eventHref(horde.runId, event.seq),
69
+ outcome: "applied" as const,
70
+ marker: true,
71
+ placeholder: false,
72
+ };
73
+ })
74
+ .filter((node) => node.at > 0);
75
+ }
76
+
77
+ function mergeNodes(input: SwarmGraphInput): GraphNode[] {
78
+ const merge = input.merge;
79
+ if (!merge) return [];
80
+ const node = (id: string, label: string, title: string, at: number): GraphNode => ({
81
+ id, kind: "lifecycle", action: id, label, title, lane: SWARM_LANE, at, eventId: "",
82
+ href: `/swarms/${encodeURIComponent(input.swarmId)}`, outcome: "applied", marker: true, placeholder: false,
83
+ });
84
+ const nodes = [node("merge-start", "merge start", "merging the hordes' deliverables", merge.startedAt)];
85
+ if (merge.endedAt !== undefined) {
86
+ nodes.push(node("merge-end", `merge end${merge.written === false ? " (no deliverable)" : ""}`,
87
+ merge.written === false ? "the swarm deliverable was not written" : "DELIVERABLE.md written", merge.endedAt));
88
+ }
89
+ return nodes;
90
+ }
91
+
92
+ /** One lane per horde plus the swarm lane, so the hordes' phases line up against the merge step. */
93
+ export function buildSwarmGraph(input: SwarmGraphInput): RunGraph {
94
+ const perHorde = input.hordes.map((horde) => hordeNodes(horde));
95
+ const nodes = [...perHorde.flat(), ...mergeNodes(input)].sort((a, b) => a.at - b.at);
96
+ const edges: GraphEdge[] = [];
97
+ if (input.merge) {
98
+ for (const horde of perHorde) {
99
+ const end = horde.find((node) => node.action === "run-end");
100
+ if (end) edges.push({ from: end.id, to: "merge-start", label: "merged", tone: "lifecycle" });
101
+ }
102
+ }
103
+ const lanes: GraphLane[] = [
104
+ { id: SWARM_LANE, label: SWARM_LANE },
105
+ ...input.hordes.map((horde) => ({
106
+ id: horde.label,
107
+ label: `${horde.label} ${horde.runId}`,
108
+ href: `/runs/${encodeURIComponent(horde.runId)}`,
109
+ status: horde.status,
110
+ })),
111
+ ];
112
+ const start = input.startedAt;
113
+ const latest = Math.max(input.now ?? 0, input.merge?.endedAt ?? 0, ...nodes.map((node) => node.at));
114
+ const end = latest > start ? latest : start + 1_000;
115
+ const hordeTimes = perHorde.flat().map((node) => node.at);
116
+ const live = input.now !== undefined && !input.hordes.every((horde) => horde.events.some((event) => event.action === "run-end"));
117
+ return {
118
+ runId: input.swarmId,
119
+ start,
120
+ end,
121
+ membersEnd: live || hordeTimes.length === 0 ? end : Math.min(end, Math.max(start, ...hordeTimes)),
122
+ lanes,
123
+ phases: [],
124
+ nodes,
125
+ edges,
126
+ };
127
+ }