agent-horde 0.1.5 → 0.2.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.
@@ -1,20 +1,40 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { USAGE, parseHordeArgs } from "../args.js";
3
+ import type { HordeTarget } from "../args.js";
3
4
  import { clearCleanStop } from "../ledger/run-signals.js";
4
5
  import { terminateAllChildren } from "../member.js";
5
6
  import { formatStatusReport } from "../prompt.js";
6
- import { requestCleanStop, setPaused, stopHard, targetMembers } from "./controls.js";
7
+ import { requestCleanStop, resolveTargets, setPaused, stopHard, targetMembers } from "./controls.js";
8
+ import type { RunSelection } from "./controls.js";
7
9
  import { stopRunTimers } from "./finalize.js";
8
- import { closeDashboard, startRun } from "./launch.js";
9
- import type { HordeRun, HordeState } from "./types.js";
10
+ import { closeDashboard, startSwarm } from "./launch.js";
11
+ import type { HordeState, Swarm } from "./types.js";
10
12
  import { unmountWidget } from "./ui.js";
11
13
 
12
- /** The active run, or undefined after telling the user there is none. */
13
- function requireActive(state: HordeState, ctx: ExtensionCommandContext): HordeRun | undefined {
14
+ /** The active swarm, or undefined after telling the user there is none. */
15
+ function requireActive(state: HordeState, ctx: ExtensionCommandContext): Swarm | undefined {
14
16
  if (!state.active) ctx.ui.notify("no horde run is active", "info");
15
17
  return state.active;
16
18
  }
17
19
 
20
+ /** The runs and members a control command addresses, or undefined after the user was told why not. */
21
+ function select(state: HordeState, target: HordeTarget, ctx: ExtensionCommandContext): RunSelection[] | undefined {
22
+ const swarm = requireActive(state, ctx);
23
+ if (!swarm) return undefined;
24
+ const selections = resolveTargets(swarm, target, ctx);
25
+ if (selections && selections.length === 0) ctx.ui.notify("the target selects no members", "info");
26
+ return selections && selections.length > 0 ? selections : undefined;
27
+ }
28
+
29
+ function showStatus(selections: RunSelection[], ctx: ExtensionCommandContext): void {
30
+ const blocks: string[] = [];
31
+ for (const { run, target } of selections) {
32
+ const members = targetMembers(run, target, ctx);
33
+ if (members) blocks.push(formatStatusReport({ ...run, members }));
34
+ }
35
+ if (blocks.length > 0) ctx.ui.notify(blocks.join("\n\n"), "info");
36
+ }
37
+
18
38
  /** Handle one `/agent-horde` invocation. */
19
39
  export async function handleHordeCommand(
20
40
  state: HordeState,
@@ -31,61 +51,72 @@ export async function handleHordeCommand(
31
51
  ctx.ui.notify(USAGE, "info");
32
52
  return;
33
53
  case "status": {
34
- const run = requireActive(state, ctx);
35
- const members = run && targetMembers(run, parsed.target, ctx);
36
- if (run && members) ctx.ui.notify(formatStatusReport({ ...run, members }), "info");
54
+ const selections = select(state, parsed.target, ctx);
55
+ if (selections) showStatus(selections, ctx);
37
56
  return;
38
57
  }
39
58
  case "stop": {
40
- const run = requireActive(state, ctx);
41
- if (!run) return;
42
- if (parsed.mode === "clean") {
43
- await requestCleanStop(run, parsed.target, ctx, {
44
- graceMs: parsed.graceMs,
45
- postmortem: parsed.postmortem,
46
- });
47
- } else {
48
- stopHard(run, parsed.target, ctx);
59
+ const selections = select(state, parsed.target, ctx);
60
+ if (!selections) return;
61
+ for (const { run, target } of selections) {
62
+ if (parsed.mode === "clean") {
63
+ await requestCleanStop(run, target, ctx, { graceMs: parsed.graceMs, postmortem: parsed.postmortem });
64
+ } else {
65
+ stopHard(run, target, ctx);
66
+ }
49
67
  }
50
68
  return;
51
69
  }
52
70
  case "pause":
53
71
  case "resume": {
54
- const run = requireActive(state, ctx);
55
- if (run) setPaused(run, parsed.target, ctx, parsed.kind === "pause");
72
+ const selections = select(state, parsed.target, ctx);
73
+ if (!selections) return;
74
+ for (const { run, target } of selections) setPaused(run, target, ctx, parsed.kind === "pause");
56
75
  return;
57
76
  }
58
77
  case "run":
59
78
  if (state.active || state.starting) {
79
+ const noun = state.active && state.active.hordes > 1 ? "swarm" : "horde";
60
80
  ctx.ui.notify(
61
- `horde ${state.active?.id ?? ""} is still active; use /agent-horde status or /agent-horde stop-hard`.replace(" ", " "),
81
+ `${noun} ${state.active?.id ?? ""} is still active; use /agent-horde status or /agent-horde stop-hard`.replace(" ", " "),
62
82
  "error",
63
83
  );
64
84
  return;
65
85
  }
66
- await startRun(state, ctx, parsed);
86
+ await startSwarm(state, ctx, parsed);
87
+ }
88
+ }
89
+
90
+ function abortSwarm(swarm: Swarm): void {
91
+ swarm.deliverableAbort?.abort();
92
+ for (const run of swarm.runs) {
93
+ run.stopped = true;
94
+ run.userStop = true;
95
+ run.postmortemAbort?.abort();
96
+ run.deliverableAbort?.abort();
97
+ stopRunTimers(run);
98
+ clearCleanStop(run.runDir);
99
+ run.controller.abort();
100
+ run.ui = undefined;
67
101
  }
102
+ try {
103
+ unmountWidget(swarm);
104
+ } catch {
105
+ // The UI is already being torn down.
106
+ }
107
+ swarm.ui = undefined;
68
108
  }
69
109
 
70
- /** Session teardown: abort the active run and every helper, without finalizing. */
110
+ /** Session teardown: abort the active swarm and every helper, without finalizing. */
71
111
  export function shutdownHorde(state: HordeState): void {
72
112
  state.disposed = true;
73
113
  process.off("exit", terminateAllChildren);
74
114
  closeDashboard(state);
75
- const run = state.active;
115
+ state.plannerAbort?.abort();
116
+ const active = state.active;
117
+ const finalizing = state.finalizing;
76
118
  state.active = undefined;
77
- if (!run) return;
78
- run.stopped = true;
79
- run.userStop = true;
80
- run.postmortemAbort?.abort();
81
- run.deliverableAbort?.abort();
82
- stopRunTimers(run);
83
- clearCleanStop(run.runDir);
84
- run.controller.abort();
85
- try {
86
- unmountWidget(run);
87
- } catch {
88
- // The UI is already being torn down.
89
- }
90
- run.ui = undefined;
119
+ state.finalizing = undefined;
120
+ if (active) abortSwarm(active);
121
+ if (finalizing && finalizing !== active) abortSwarm(finalizing);
91
122
  }
@@ -1,5 +1,6 @@
1
1
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
2
- import type { HordeRun } from "./types.js";
2
+ import { hordeLabel } from "./types.js";
3
+ import type { Swarm } from "./types.js";
3
4
 
4
5
  const CONTROL_SUBCOMMANDS: Array<{
5
6
  value: string;
@@ -25,7 +26,12 @@ const NO_VERIFIER_COMPLETION = {
25
26
  description: "do not assign the verifier role to the last member of a 3+ member run",
26
27
  };
27
28
 
28
- const LAUNCH_FLAG_COMPLETIONS = [DASHBOARD_COMPLETION, NO_VERIFIER_COMPLETION];
29
+ const UNTIL_STOP_COMPLETION = {
30
+ value: "--until-stop ",
31
+ description: "keep the whole horde working until stop-clean or stop-hard; validated solves are banked",
32
+ };
33
+
34
+ const LAUNCH_FLAG_COMPLETIONS = [DASHBOARD_COMPLETION, NO_VERIFIER_COMPLETION, UNTIL_STOP_COMPLETION];
29
35
 
30
36
  /** Argument completions that are not member targets. */
31
37
  const EXTRA_COMPLETIONS: Record<string, Array<{ value: string; description: string }>> = {
@@ -36,7 +42,24 @@ const EXTRA_COMPLETIONS: Record<string, Array<{ value: string; description: stri
36
42
  plan: [DASHBOARD_COMPLETION],
37
43
  };
38
44
 
39
- export function controlCompletions(prefix: string, active: HordeRun | undefined): AutocompleteItem[] | null {
45
+ /** `all`, then the members of a single horde, or `hN` and `hN:member-M` for a larger swarm. */
46
+ function targetNames(active: Swarm | undefined): Array<{ name: string; description: string }> {
47
+ if (!active) return [{ name: "all", description: "all members" }];
48
+ const total = active.runs.reduce((sum, run) => sum + run.members.length, 0);
49
+ const names = [{ name: "all", description: active.hordes > 1 ? `all ${total} members of ${active.hordes} hordes` : `all ${total} members` }];
50
+ if (active.hordes === 1) {
51
+ for (const member of active.runs[0]?.members ?? []) names.push({ name: member.name, description: member.status });
52
+ return names;
53
+ }
54
+ for (const run of active.runs) {
55
+ const label = hordeLabel(run.horde);
56
+ names.push({ name: label, description: `horde ${label}: ${run.members.length} members` });
57
+ for (const member of run.members) names.push({ name: `${label}:${member.name}`, description: member.status });
58
+ }
59
+ return names;
60
+ }
61
+
62
+ export function controlCompletions(prefix: string, active: Swarm | undefined): AutocompleteItem[] | null {
40
63
  const space = prefix.search(/\s/);
41
64
  if (space === -1 && prefix.startsWith("-")) {
42
65
  const items = LAUNCH_FLAG_COMPLETIONS.filter((flag) => flag.value.startsWith(prefix)).map(
@@ -61,18 +84,12 @@ export function controlCompletions(prefix: string, active: HordeRun | undefined)
61
84
  const match = /^([\s\S]*?)([^\s,]*)$/.exec(rest);
62
85
  const base = match?.[1] ?? "";
63
86
  const token = match?.[2] ?? "";
64
- const names = active && sub.takesTarget ? active.members.map((m) => m.name) : [];
65
- const targetItems: AutocompleteItem[] = (sub.takesTarget ? ["all", ...names] : [])
66
- .filter((name) => name.startsWith(token))
67
- .map((name) => ({
68
- value: `${head} ${base}${name}`,
69
- label: name,
70
- description:
71
- name === "all"
72
- ? active
73
- ? `all ${active.count} members`
74
- : "all members"
75
- : active?.members.find((m) => m.name === name)?.status,
87
+ const targetItems: AutocompleteItem[] = (sub.takesTarget ? targetNames(active) : [])
88
+ .filter((target) => target.name.startsWith(token))
89
+ .map((target) => ({
90
+ value: `${head} ${base}${target.name}`,
91
+ label: target.name,
92
+ description: target.description,
76
93
  }));
77
94
  const extraItems: AutocompleteItem[] = (EXTRA_COMPLETIONS[head] ?? [])
78
95
  .filter((extra) => {
@@ -5,7 +5,8 @@ import { postBoard } from "../ledger/board.js";
5
5
  import { readCleanStop, readSolvedState, writeCleanStop } from "../ledger/run-signals.js";
6
6
  import { recordLifecycle } from "./lifecycle.js";
7
7
  import type { MemberState } from "../member.js";
8
- import type { HordeRun } from "./types.js";
8
+ import { hordeLabel } from "./types.js";
9
+ import type { HordeRun, Swarm } from "./types.js";
9
10
  import { refreshRun, safeNotify } from "./ui.js";
10
11
 
11
12
  /** How long stop-clean lets members wrap up before it escalates to stop-hard. */
@@ -26,10 +27,63 @@ interface CleanStopOptions {
26
27
  label?: CleanStopLabel | undefined;
27
28
  }
28
29
 
29
- function selectedMembers(
30
- run: HordeRun,
30
+ /** A control target resolved to one run: every member, or the named ones. */
31
+ export type RunTarget = { kind: "all" } | { kind: "members"; names: string[] };
32
+
33
+ /** One run a control command applies to, with its share of the target. */
34
+ export interface RunSelection {
35
+ run: HordeRun;
36
+ target: RunTarget;
37
+ }
38
+
39
+ /**
40
+ * Resolve a target across the swarm: `all` and `hN` select whole runs, a bare member selects
41
+ * that member in every horde, `hN:member-M` one member of one horde. Runs the target does not
42
+ * touch are left out. Notifies and returns undefined when a horde or member does not exist.
43
+ */
44
+ export function resolveTargets(
45
+ swarm: Swarm,
31
46
  target: HordeTarget,
32
- ): { members: MemberState[]; invalid: string[] } {
47
+ ctx: ExtensionCommandContext,
48
+ ): RunSelection[] | undefined {
49
+ if (target.kind === "all") return swarm.runs.map((run) => ({ run, target: { kind: "all" } }));
50
+ const whole = new Set<HordeRun>();
51
+ const named = new Map<HordeRun, Set<string>>();
52
+ const invalid: string[] = [];
53
+ for (const item of target.items) {
54
+ const runs = item.horde === undefined ? swarm.runs : swarm.runs.filter((run) => run.horde === item.horde);
55
+ if (runs.length === 0) {
56
+ invalid.push(hordeLabel(item.horde ?? 0));
57
+ continue;
58
+ }
59
+ for (const run of runs) {
60
+ if (item.member === undefined) {
61
+ whole.add(run);
62
+ continue;
63
+ }
64
+ if (!run.members.some((member) => member.name === item.member)) {
65
+ invalid.push(swarm.hordes > 1 ? `${hordeLabel(run.horde)}:${item.member}` : item.member);
66
+ continue;
67
+ }
68
+ if (!named.has(run)) named.set(run, new Set());
69
+ named.get(run)!.add(item.member);
70
+ }
71
+ }
72
+ if (invalid.length > 0) {
73
+ const hordes = invalid.filter((name) => !name.includes("member"));
74
+ const members = invalid.filter((name) => name.includes("member"));
75
+ if (hordes.length > 0) ctx.ui.notify(`no such horde: ${[...new Set(hordes)].join(", ")}`, "error");
76
+ if (members.length > 0) ctx.ui.notify(`no such member: ${[...new Set(members)].join(", ")}`, "error");
77
+ return undefined;
78
+ }
79
+ return swarm.runs.flatMap((run): RunSelection[] => {
80
+ if (whole.has(run)) return [{ run, target: { kind: "all" } }];
81
+ const names = named.get(run);
82
+ return names ? [{ run, target: { kind: "members", names: [...names] } }] : [];
83
+ });
84
+ }
85
+
86
+ function selectedMembers(run: HordeRun, target: RunTarget): { members: MemberState[]; invalid: string[] } {
33
87
  if (target.kind === "all") return { members: run.members, invalid: [] };
34
88
  const known = new Set(run.members.map((m) => m.name));
35
89
  return {
@@ -38,10 +92,10 @@ function selectedMembers(
38
92
  };
39
93
  }
40
94
 
41
- /** Resolve a target, or notify and return undefined when it names unknown members. */
95
+ /** Resolve a run target, or notify and return undefined when it names unknown members. */
42
96
  export function targetMembers(
43
97
  run: HordeRun,
44
- target: HordeTarget,
98
+ target: RunTarget,
45
99
  ctx: ExtensionCommandContext,
46
100
  ): MemberState[] | undefined {
47
101
  const { members, invalid } = selectedMembers(run, target);
@@ -63,7 +117,12 @@ export function hardStopMembers(run: HordeRun, names: string[]): void {
63
117
  }
64
118
  }
65
119
 
66
- export function stopHard(run: HordeRun, target: HordeTarget, ctx: ExtensionCommandContext): void {
120
+ /** `h2: ` in a swarm's notifications, nothing for a swarm of one. */
121
+ function prefix(run: HordeRun): string {
122
+ return (run.hordes ?? 1) > 1 ? `${hordeLabel(run.horde)}: ` : "";
123
+ }
124
+
125
+ export function stopHard(run: HordeRun, target: RunTarget, ctx: ExtensionCommandContext): void {
67
126
  const members = targetMembers(run, target, ctx);
68
127
  if (!members) return;
69
128
  const names = members.map((m) => m.name);
@@ -72,13 +131,13 @@ export function stopHard(run: HordeRun, target: HordeTarget, ctx: ExtensionComma
72
131
  run.stopLabel = "stopped by user";
73
132
  hardStopMembers(run, names);
74
133
  void recordLifecycle(run, "stop-hard", { members: names, by: "user" });
75
- ctx.ui.notify(`stop-hard: terminating ${names.join(", ")}`, "warning");
134
+ ctx.ui.notify(`${prefix(run)}stop-hard: terminating ${names.join(", ")}`, "warning");
76
135
  }
77
136
 
78
137
  /** Suspend (SIGSTOP) or resume (SIGCONT) the targeted live members. */
79
138
  export function setPaused(
80
139
  run: HordeRun,
81
- target: HordeTarget,
140
+ target: RunTarget,
82
141
  ctx: ExtensionCommandContext,
83
142
  paused: boolean,
84
143
  ): void {
@@ -104,7 +163,7 @@ export function setPaused(
104
163
  : changed > 0
105
164
  ? `resumed ${changed} member(s)`
106
165
  : "no paused members to resume";
107
- ctx.ui.notify(message, "info");
166
+ ctx.ui.notify(`${prefix(run)}${message}`, "info");
108
167
  }
109
168
 
110
169
  function stopLabelFor(label: CleanStopLabel): string {
@@ -134,7 +193,7 @@ function holdCleanStop(
134
193
  run.cleanStopUntil = Date.now() + holdMs;
135
194
  run.cleanStopTimer = setTimeout(() => finishCleanStop(run, members, label), holdMs);
136
195
  run.cleanStopTimer.unref?.();
137
- safeNotify(run, message, "info");
196
+ safeNotify(run, `${prefix(run)}${message}`, "info");
138
197
  refreshRun(run);
139
198
  }
140
199
 
@@ -184,7 +243,7 @@ function finishCleanStop(run: HordeRun, members: MemberState[], label: CleanStop
184
243
  run.cleanStopUntil = undefined;
185
244
  hardStopMembers(run, run.cleanStopNames);
186
245
  void recordLifecycle(run, "stop-hard", { members: run.cleanStopNames, by: label });
187
- safeNotify(run, `${label} deadline reached: terminating ${run.cleanStopNames.join(", ")}`, "warning");
246
+ safeNotify(run, `${prefix(run)}${label} deadline reached: terminating ${run.cleanStopNames.join(", ")}`, "warning");
188
247
  refreshRun(run);
189
248
  }
190
249
 
@@ -202,7 +261,7 @@ function wakePaused(run: HordeRun, members: MemberState[]): void {
202
261
  /** Ask the targeted members to wrap up, with a hard stop once the grace period ends. */
203
262
  export async function requestCleanStop(
204
263
  run: HordeRun,
205
- target: HordeTarget,
264
+ target: RunTarget,
206
265
  ctx: ExtensionCommandContext,
207
266
  options: CleanStopOptions = {},
208
267
  ): Promise<void> {
@@ -210,7 +269,7 @@ export async function requestCleanStop(
210
269
  const members = targetMembers(run, target, ctx);
211
270
  if (!members) return;
212
271
  if (members.length === 0) {
213
- ctx.ui.notify("no running members to stop", "info");
272
+ ctx.ui.notify(`${prefix(run)}no running members to stop`, "info");
214
273
  return;
215
274
  }
216
275
  const grace = options.graceMs ?? CLEAN_STOP_GRACE_MS;
@@ -236,7 +295,7 @@ export async function requestCleanStop(
236
295
  refreshRun(run);
237
296
  const graceSeconds = Math.round(grace / 1000);
238
297
  ctx.ui.notify(
239
- `${label}: ${requested.length} member(s) wrapping up, hard stop in ${graceSeconds}s`,
298
+ `${prefix(run)}${label}: ${requested.length} member(s) wrapping up, hard stop in ${graceSeconds}s`,
240
299
  "warning",
241
300
  );
242
301
  try {
@@ -20,7 +20,7 @@ import { clusterFindings } from "../ledger/clusters.js";
20
20
  import { listFacts } from "../ledger/facts.js";
21
21
  import type { FactEntry } from "../ledger/facts.js";
22
22
  import { readHordeFile } from "../ledger/files.js";
23
- import { listFindingsForRun, parseFindings } from "../ledger/findings.js";
23
+ import { listFindingsForRun, parseFindings, supersededCitations } from "../ledger/findings.js";
24
24
  import type { FindingEntry } from "../ledger/findings.js";
25
25
  import { countIdeas, parseIdeas, reconcileOwnedIdeas } from "../ledger/ideas.js";
26
26
  import type { Idea } from "../ledger/ideas.js";
@@ -35,9 +35,10 @@ import { runGraphMarkdown } from "../run-graph-mermaid.js";
35
35
  import type { CleanupStatus, ReportResource, RunReportInput } from "../prompt.js";
36
36
  import { buildDiagnosis } from "./diagnosis.js";
37
37
  import { recordLifecycle, recordPhase } from "./lifecycle.js";
38
+ import { noteRunFinalized, noteRunReported } from "./swarm-finalize.js";
38
39
  import { runDeliverable, runPostmortem } from "./synthesis.js";
39
- import type { HordeRun, HordeState } from "./types.js";
40
- import { REPORT_TYPE, refreshRun, reportDetails, safeNotify, unmountWidget } from "./ui.js";
40
+ import type { HordeRun, HordeState, Swarm } from "./types.js";
41
+ import { REPORT_TYPE, refreshRun, reportDetails, safeNotify } from "./ui.js";
41
42
 
42
43
  /** Most deliverable warnings shown inline; REPORT.md keeps the full list. */
43
44
  const INLINE_WARNING_CAP = 12;
@@ -109,6 +110,11 @@ function settleSolved(run: HordeRun): SettledAnswer | undefined {
109
110
  ) {
110
111
  run.solvedValidatedBy = solvedState.confirm.member;
111
112
  }
113
+ // `--until-stop`: the last banked answer stands unless a later proposal was validated too.
114
+ if (run.lastRound && !(run.solved && run.solvedValidatedBy)) {
115
+ run.solved = run.lastRound.proposal;
116
+ run.solvedValidatedBy = run.lastRound.validator;
117
+ }
112
118
  if (run.solved) {
113
119
  const status = run.solvedValidatedBy
114
120
  ? `validated by ${run.solvedValidatedBy}`
@@ -132,12 +138,6 @@ interface SettledAnswer {
132
138
  }
133
139
 
134
140
  /** Findings the proposal names that a later finding corrected; labeled, not re-versioned. */
135
- export function supersededCitations(text: string, findings: FindingEntry[]): string[] {
136
- return findings
137
- .filter((finding) => finding.supersededBy !== undefined && new RegExp(`\\bF${finding.id}\\b`).test(text))
138
- .map((finding) => `F${finding.id} (now F${finding.supersededBy})`);
139
- }
140
-
141
141
  /**
142
142
  * The proposal body is the member's verbatim text and can predate later challenges and corrections.
143
143
  * The status names the blockers still open at freeze and the findings a later finding superseded,
@@ -280,12 +280,11 @@ async function runCleanup(run: HordeRun, item: PendingCleanup): Promise<CleanupR
280
280
  * Tear down the labs this run registered before writing the report, so the
281
281
  * report states whether cleanup succeeded and no containers outlive the run.
282
282
  */
283
- async function runCleanups(state: HordeState, run: HordeRun): Promise<CleanupResult[]> {
283
+ async function runCleanups(run: HordeRun): Promise<CleanupResult[]> {
284
284
  const pending =
285
285
  process.env.PI_HORDE_CLEANUP === "off" ? [] : pendingCleanups(run.hordeDir, run.id);
286
286
  if (pending.length === 0) return [];
287
287
  run.finalizing = ["cleanup"];
288
- state.finalizing = run;
289
288
  refreshRun(run);
290
289
  const cleanups: CleanupResult[] = [];
291
290
  for (const item of pending) cleanups.push(await runCleanup(run, item));
@@ -338,6 +337,7 @@ function writeRunReports(
338
337
  });
339
338
  const memberPosts = lastBoardPostsByMember(run.hordeDir, run.id);
340
339
  const outcome = runOutcome(run);
340
+ run.outcome = outcome;
341
341
  let events: HordeEvent[] = [];
342
342
  try {
343
343
  events = readEvents(run.hordeDir, run.id);
@@ -462,7 +462,6 @@ async function synthesize(
462
462
  if (wantPostmortem) steps.push("post-mortem");
463
463
  if (steps.length > 0) {
464
464
  run.finalizing = steps;
465
- state.finalizing = run;
466
465
  refreshRun(run);
467
466
  }
468
467
  run.deliverable = undefined;
@@ -503,7 +502,15 @@ async function synthesize(
503
502
  }
504
503
  }
505
504
 
506
- function sendReport(state: HordeState, run: HordeRun, ledgers: RunLedgers, now: number): void {
505
+ /**
506
+ * Inject the run report into the parent conversation. A horde of a larger swarm only notifies:
507
+ * the swarm sends one merged report once every horde has finished.
508
+ */
509
+ function sendReport(state: HordeState, swarm: Swarm, run: HordeRun, ledgers: RunLedgers, now: number): void {
510
+ if (swarm.hordes > 1) {
511
+ safeNotify(run, `horde ${run.id} ${run.stopped ? "stopped" : "finished"} (${run.outcome ?? "finished"})`, "info");
512
+ return;
513
+ }
507
514
  const sections = [reportText(run, ledgers.ideaCounts, ledgers.findings.length, now)];
508
515
  if (run.deliverable) sections.push("## Deliverable", "", run.deliverable);
509
516
  if (run.postmortem) sections.push("## Post-mortem", "", run.postmortem);
@@ -536,8 +543,11 @@ export function writeRunGraph(run: HordeRun): void {
536
543
  }
537
544
  }
538
545
 
539
- /** Wind a run down once every member has exited: reconcile, report, synthesize, notify. */
540
- export async function finalizeRun(state: HordeState, run: HordeRun): Promise<void> {
546
+ /**
547
+ * Wind a run down once every member has exited: reconcile, report, synthesize, notify. The
548
+ * swarm's slot frees once every horde has reported, and the swarm step follows the last finalize.
549
+ */
550
+ export async function finalizeRun(state: HordeState, swarm: Swarm, run: HordeRun): Promise<void> {
541
551
  const now = Date.now();
542
552
  try {
543
553
  if (state.disposed) return;
@@ -553,21 +563,20 @@ export async function finalizeRun(state: HordeState, run: HordeRun): Promise<voi
553
563
  (acceptance.issues.length > 0 || supersededCitations(answer.proposal.text, ledgers.findings).length > 0);
554
564
  if (annotate) annotateAnswer(run, answer, acceptance, ledgers.findings);
555
565
  // Workers and bounded repairs have exited; only now may their labs be torn down.
556
- const cleanups = await runCleanups(state, run);
566
+ const cleanups = await runCleanups(run);
557
567
  const reportInput = writeRunReports(run, ledgers, releasedIdeas, cleanups, now);
558
568
  // Free the slot before the (possibly slow) synthesis so a new run can start.
559
- if (state.active === run) state.active = undefined;
569
+ noteRunReported(state, swarm, run);
560
570
  await synthesize(state, run, ledgers, reportInput, now);
561
571
  if (annotate && run.deliverable) annotateAnswer(run, answer, acceptance, ledgers.findings);
562
- sendReport(state, run, ledgers, now);
572
+ sendReport(state, swarm, run, ledgers, now);
563
573
  await recordLifecycle(run, "run-end", { outcome: runOutcome(run), elapsedMs: Date.now() - run.startedAt, stopLabel: run.stopLabel });
564
574
  writeRunGraph(run);
565
575
  } catch (error) {
566
576
  safeNotify(run, `horde ${run.id} failed to finalize: ${errorMessage(error)}`, "error");
567
577
  } finally {
568
- if (state.active === run) state.active = undefined;
578
+ noteRunReported(state, swarm, run);
569
579
  run.finalizing = undefined;
570
- if (state.finalizing === run) state.finalizing = undefined;
571
- unmountWidget(run);
580
+ await noteRunFinalized(state, swarm, run);
572
581
  }
573
582
  }