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.
package/src/prompt.ts CHANGED
@@ -9,7 +9,7 @@ import type { PeerMessage, ReviewRecord } from "./ledger/collaboration.js";
9
9
  import type { Idea } from "./ledger/ideas.js";
10
10
  import type { ResourceEntry } from "./ledger/resources.js";
11
11
  import type { SolvedSignal } from "./ledger/run-signals.js";
12
- import type { MemberReset, MemberState } from "./member.js";
12
+ import type { MemberReset, MemberState, SiblingHorde } from "./member.js";
13
13
 
14
14
  /** A resource with the non-owner members whose findings, evidence, reviews or leases cite it. */
15
15
  export type ReportResource = ResourceEntry & { citedBy?: string[] | undefined };
@@ -34,6 +34,8 @@ export interface RunSummaryInput {
34
34
  solvedCommitted?: boolean | undefined;
35
35
  /** Members whose queued repair work was skipped for budget. */
36
36
  repairSkipped?: string[] | undefined;
37
+ /** Members held back from later rounds because their attempt left every thread unchanged. */
38
+ repairStalled?: string[] | undefined;
37
39
  /** The REPORT.md outcome, so SUMMARY.md states the same end cause. */
38
40
  outcome?: string | undefined;
39
41
  /** One-line run diagnosis shown in the report message. */
@@ -126,6 +128,8 @@ Collaboration loop:
126
128
  a finding on that idea, explicitly set responseTarget=F#. Superseded F# requests follow the correction.
127
129
  Requester: attach/review evidence on the current response target, then resolve target=Q# refs=[E#]
128
130
  revision=current-hash text=decision, or defer with a reason. Disclosed finding limitations remain debt.
131
+ A blocking request about your own work resolves on the recipient's full supporting review of the
132
+ current revision, since you cannot review it yourself.
129
133
  Do not repeat your existing check: inspect collaboration first and test a specific remaining gap.
130
134
  A deferred blocking issue stays visible as acceptance debt; its recipient can still respond to it and
131
135
  the requester then resolves it. Never silently ignore a peer's failed test.
@@ -169,7 +173,26 @@ Collaboration loop:
169
173
  attempt after discovery exits. Repair attempts must address assigned feedback only.
170
174
  `;
171
175
 
172
- export function memberSystemPrompt(input: { hordeDir: string; count: number }): string {
176
+ /** What a member of a swarm is told about the other hordes: shared read, local write. */
177
+ function siblingsSection(siblings: SiblingHorde[]): string {
178
+ if (siblings.length === 0) return "";
179
+ const lines = siblings.map(
180
+ (sibling) => `- ${sibling.label}: ${sibling.hordeDir}/${sibling.slice ? ` (workstream: ${sibling.slice})` : ""}`,
181
+ );
182
+ return `
183
+ Your horde is one of ${siblings.length + 1} hordes working the same task in parallel, each with its
184
+ own ledgers. The other hordes' directories are read-only for you:
185
+
186
+ ${lines.join("\n")}
187
+
188
+ 'horde facts' and 'horde resources' include their entries tagged [hN], and 'horde list' includes
189
+ their done and ruled-out ideas, so a fact, a rule-out, or a resource another horde recorded is not
190
+ re-derived here; reuse a sibling's resource by its path. Every write goes to your own horde's
191
+ ledgers; never write to a sibling's directory, and never claim or cite a sibling's ideas as yours.
192
+ `;
193
+ }
194
+
195
+ export function memberSystemPrompt(input: { hordeDir: string; count: number; siblings?: SiblingHorde[] | undefined }): string {
173
196
  const count = input.count;
174
197
  return `You are one member of a horde of ${count} agents. Every member received the same task
175
198
  and works in parallel in this repository with its own separate context. Coordinate through the
@@ -188,9 +211,9 @@ horde tool. Durable run events retain messages, evidence, reviews and revisions;
188
211
  - PLAN.md workstream partition and your assigned layer, when the run was started with plan.
189
212
 
190
213
  Read them with the read tool. Write them ONLY with the horde tool; never edit them directly.
191
- Put reproduction snippets under .horde/runs/<run-id>/artifacts/ using PI_HORDE_RUN, so evidence
192
- survives finalization and line references do not drift.
193
-
214
+ Put reproduction snippets under ${input.hordeDir}/runs/<run-id>/artifacts/ using PI_HORDE_RUN, so
215
+ evidence survives finalization and line references do not drift.
216
+ ${siblingsSection(input.siblings ?? [])}
194
217
  Protocol:
195
218
  1. Run 'horde list', 'horde findings', 'horde facts', and read BOARD.md before planning.
196
219
  2. Prefer the shared queue. Add an idea only for an uncovered question or concrete evidence gap.
@@ -254,7 +277,8 @@ Protocol:
254
277
  proposal reopens for validation once every idea it named is done or ruled out. Pass the revision
255
278
  from ANSWER.md or 'horde inspect' target=answer to either action, so stale reviews cannot approve
256
279
  a newer proposal. An answer that cites a superseded F# or quotes a provisional T# is refused, and a
257
- pending proposal is withdrawn for re-proposal when a finding it cites is superseded. A request to
280
+ pending proposal is withdrawn for re-proposal when a finding it cites is superseded or amended,
281
+ or an artifact it cites is republished. A request to
258
282
  the proposer or a validator about the pending answer (target=answer) is served with the blockers.
259
283
  The parent enforces coverage before wind-down.
260
284
 
@@ -275,7 +299,10 @@ export function taskMarkdown(input: {
275
299
  startedAt: number;
276
300
  verifier?: string | undefined;
277
301
  untilStop?: boolean | undefined;
302
+ /** Set for a horde of a larger swarm: which horde this is and its workstream. */
303
+ swarm?: { id: string; horde: number; hordes: number; slice?: string | undefined } | undefined;
278
304
  }): string {
305
+ const swarm = input.swarm;
279
306
  return [
280
307
  `# Horde run ${input.id}`,
281
308
  "",
@@ -285,6 +312,8 @@ export function taskMarkdown(input: {
285
312
  `Thinking: ${input.thinkingLevel ?? "parent default"}`,
286
313
  `Verifier: ${input.verifier ?? "none"}`,
287
314
  `Until stop: ${input.untilStop ? "yes (the run continues until stop-clean or stop-hard)" : "no"}`,
315
+ ...(swarm ? [`Swarm: ${swarm.id} (horde h${swarm.horde} of ${swarm.hordes})`] : []),
316
+ ...(swarm?.slice ? [`Workstream: ${swarm.slice}`] : []),
288
317
  `Task-SHA256: ${taskHash(input.prompt)}`,
289
318
  "",
290
319
  "## Prompt",
@@ -438,10 +467,12 @@ export function runEndSentence(run: RunSummaryInput): string {
438
467
  const attempts = run.members.reduce((n, m) => n + (m.repairs ?? 0), 0);
439
468
  const killed = run.members.filter((m) => ["tokens", "turns", "time"].includes(m.repairStopReason ?? ""));
440
469
  const skipped = run.repairSkipped ?? [];
470
+ const stalled = run.repairStalled ?? [];
441
471
  if (attempts > 0 || skipped.length > 0) {
442
472
  const detail = [
443
473
  killed.length ? `${killed.length} killed on budget (${killed.map((m) => `${m.name}: ${m.repairStopReason}`).join(", ")})` : "",
444
474
  skipped.length ? `${skipped.length} skipped for budget (${skipped.join(", ")})` : "",
475
+ stalled.length ? `${stalled.length} held after no progress (${stalled.join(", ")})` : "",
445
476
  ].filter(Boolean).join(", ");
446
477
  parts.push(`Repair: ${attempts} attempt(s)${detail ? `, ${detail}` : ""}.`);
447
478
  }
@@ -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;
@@ -41,7 +42,24 @@ const EXTRA_COMPLETIONS: Record<string, Array<{ value: string; description: stri
41
42
  plan: [DASHBOARD_COMPLETION],
42
43
  };
43
44
 
44
- 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 {
45
63
  const space = prefix.search(/\s/);
46
64
  if (space === -1 && prefix.startsWith("-")) {
47
65
  const items = LAUNCH_FLAG_COMPLETIONS.filter((flag) => flag.value.startsWith(prefix)).map(
@@ -66,18 +84,12 @@ export function controlCompletions(prefix: string, active: HordeRun | undefined)
66
84
  const match = /^([\s\S]*?)([^\s,]*)$/.exec(rest);
67
85
  const base = match?.[1] ?? "";
68
86
  const token = match?.[2] ?? "";
69
- const names = active && sub.takesTarget ? active.members.map((m) => m.name) : [];
70
- const targetItems: AutocompleteItem[] = (sub.takesTarget ? ["all", ...names] : [])
71
- .filter((name) => name.startsWith(token))
72
- .map((name) => ({
73
- value: `${head} ${base}${name}`,
74
- label: name,
75
- description:
76
- name === "all"
77
- ? active
78
- ? `all ${active.count} members`
79
- : "all members"
80
- : 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,
81
93
  }));
82
94
  const extraItems: AutocompleteItem[] = (EXTRA_COMPLETIONS[head] ?? [])
83
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, supersededCitations } from "../ledger/findings.js";
23
+ import { listFindingsForRun, parseFindings, staleAnswerFor } 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;
@@ -136,19 +137,18 @@ interface SettledAnswer {
136
137
  status: string;
137
138
  }
138
139
 
139
- /** Findings the proposal names that a later finding corrected; labeled, not re-versioned. */
140
140
  /**
141
141
  * The proposal body is the member's verbatim text and can predate later challenges and corrections.
142
- * The status names the blockers still open at freeze and the findings a later finding superseded,
143
- * and, once synthesis wrote it, points the operator at DELIVERABLE.md as the reconciled output.
142
+ * The status names the blockers still open at freeze and the citations that changed after the
143
+ * proposal pinned them (superseded or amended findings, republished artifacts), and, once
144
+ * synthesis wrote it, points the operator at DELIVERABLE.md as the reconciled output.
144
145
  */
145
- function annotateAnswer(run: HordeRun, answer: SettledAnswer, acceptance: { hash: string; issues: string[] }, findings: FindingEntry[]): void {
146
+ function annotateAnswer(run: HordeRun, answer: SettledAnswer, acceptance: { hash: string; issues: string[] }, stale: string[]): void {
146
147
  const blockers = acceptance.issues.filter((issue) => /^Q\d+ .*: \w+ blocking /.test(issue)).map((issue) => issue.replace(/:/, ""));
147
148
  const parts = [answer.status];
148
149
  if (acceptance.issues.length) parts.push("acceptance incomplete");
149
150
  if (blockers.length) parts.push(`open blocking at freeze: ${blockers.join(", ")}`);
150
- const superseded = supersededCitations(answer.proposal.text, findings);
151
- if (superseded.length) parts.push(`cites superseded ${superseded.join(", ")}`);
151
+ if (stale.length) parts.push(`cites ${stale.join(", ")}`);
152
152
  parts.push(`see ACCEPTANCE.md (snapshot ${acceptance.hash})`);
153
153
  if (run.deliverable) parts.push("reconciled operator output: DELIVERABLE.md; the proposal text below is verbatim and may be superseded there");
154
154
  try {
@@ -279,12 +279,11 @@ async function runCleanup(run: HordeRun, item: PendingCleanup): Promise<CleanupR
279
279
  * Tear down the labs this run registered before writing the report, so the
280
280
  * report states whether cleanup succeeded and no containers outlive the run.
281
281
  */
282
- async function runCleanups(state: HordeState, run: HordeRun): Promise<CleanupResult[]> {
282
+ async function runCleanups(run: HordeRun): Promise<CleanupResult[]> {
283
283
  const pending =
284
284
  process.env.PI_HORDE_CLEANUP === "off" ? [] : pendingCleanups(run.hordeDir, run.id);
285
285
  if (pending.length === 0) return [];
286
286
  run.finalizing = ["cleanup"];
287
- state.finalizing = run;
288
287
  refreshRun(run);
289
288
  const cleanups: CleanupResult[] = [];
290
289
  for (const item of pending) cleanups.push(await runCleanup(run, item));
@@ -337,6 +336,7 @@ function writeRunReports(
337
336
  });
338
337
  const memberPosts = lastBoardPostsByMember(run.hordeDir, run.id);
339
338
  const outcome = runOutcome(run);
339
+ run.outcome = outcome;
340
340
  let events: HordeEvent[] = [];
341
341
  try {
342
342
  events = readEvents(run.hordeDir, run.id);
@@ -461,7 +461,6 @@ async function synthesize(
461
461
  if (wantPostmortem) steps.push("post-mortem");
462
462
  if (steps.length > 0) {
463
463
  run.finalizing = steps;
464
- state.finalizing = run;
465
464
  refreshRun(run);
466
465
  }
467
466
  run.deliverable = undefined;
@@ -502,7 +501,15 @@ async function synthesize(
502
501
  }
503
502
  }
504
503
 
505
- function sendReport(state: HordeState, run: HordeRun, ledgers: RunLedgers, now: number): void {
504
+ /**
505
+ * Inject the run report into the parent conversation. A horde of a larger swarm only notifies:
506
+ * the swarm sends one merged report once every horde has finished.
507
+ */
508
+ function sendReport(state: HordeState, swarm: Swarm, run: HordeRun, ledgers: RunLedgers, now: number): void {
509
+ if (swarm.hordes > 1) {
510
+ safeNotify(run, `horde ${run.id} ${run.stopped ? "stopped" : "finished"} (${run.outcome ?? "finished"})`, "info");
511
+ return;
512
+ }
506
513
  const sections = [reportText(run, ledgers.ideaCounts, ledgers.findings.length, now)];
507
514
  if (run.deliverable) sections.push("## Deliverable", "", run.deliverable);
508
515
  if (run.postmortem) sections.push("## Post-mortem", "", run.postmortem);
@@ -535,8 +542,11 @@ export function writeRunGraph(run: HordeRun): void {
535
542
  }
536
543
  }
537
544
 
538
- /** Wind a run down once every member has exited: reconcile, report, synthesize, notify. */
539
- export async function finalizeRun(state: HordeState, run: HordeRun): Promise<void> {
545
+ /**
546
+ * Wind a run down once every member has exited: reconcile, report, synthesize, notify. The
547
+ * swarm's slot frees once every horde has reported, and the swarm step follows the last finalize.
548
+ */
549
+ export async function finalizeRun(state: HordeState, swarm: Swarm, run: HordeRun): Promise<void> {
540
550
  const now = Date.now();
541
551
  try {
542
552
  if (state.disposed) return;
@@ -548,25 +558,24 @@ export async function finalizeRun(state: HordeState, run: HordeRun): Promise<voi
548
558
  run.acceptance = acceptance.status;
549
559
  run.snapshotHash = acceptance.hash;
550
560
  await recordLifecycle(run, "acceptance", { status: acceptance.status, hash: acceptance.hash, issues: acceptance.issues.length });
551
- const annotate = answer !== undefined &&
552
- (acceptance.issues.length > 0 || supersededCitations(answer.proposal.text, ledgers.findings).length > 0);
553
- if (annotate) annotateAnswer(run, answer, acceptance, ledgers.findings);
561
+ const stale = answer ? staleAnswerFor(run.hordeDir, run.id, answer.proposal.text, answer.proposal.cites) : [];
562
+ const annotate = answer !== undefined && (acceptance.issues.length > 0 || stale.length > 0);
563
+ if (annotate) annotateAnswer(run, answer, acceptance, stale);
554
564
  // Workers and bounded repairs have exited; only now may their labs be torn down.
555
- const cleanups = await runCleanups(state, run);
565
+ const cleanups = await runCleanups(run);
556
566
  const reportInput = writeRunReports(run, ledgers, releasedIdeas, cleanups, now);
557
567
  // Free the slot before the (possibly slow) synthesis so a new run can start.
558
- if (state.active === run) state.active = undefined;
568
+ noteRunReported(state, swarm, run);
559
569
  await synthesize(state, run, ledgers, reportInput, now);
560
- if (annotate && run.deliverable) annotateAnswer(run, answer, acceptance, ledgers.findings);
561
- sendReport(state, run, ledgers, now);
570
+ if (annotate && run.deliverable) annotateAnswer(run, answer, acceptance, stale);
571
+ sendReport(state, swarm, run, ledgers, now);
562
572
  await recordLifecycle(run, "run-end", { outcome: runOutcome(run), elapsedMs: Date.now() - run.startedAt, stopLabel: run.stopLabel });
563
573
  writeRunGraph(run);
564
574
  } catch (error) {
565
575
  safeNotify(run, `horde ${run.id} failed to finalize: ${errorMessage(error)}`, "error");
566
576
  } finally {
567
- if (state.active === run) state.active = undefined;
577
+ noteRunReported(state, swarm, run);
568
578
  run.finalizing = undefined;
569
- if (state.finalizing === run) state.finalizing = undefined;
570
- unmountWidget(run);
579
+ await noteRunFinalized(state, swarm, run);
571
580
  }
572
581
  }