agent-horde 0.1.6 → 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.
package/src/run/launch.ts CHANGED
@@ -1,7 +1,9 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
4
  import type { DashboardOption, HordeRunArgs } from "../args.js";
3
5
  import { resolveDashboardPort, startDashboard } from "../dashboard.js";
4
- import { errorMessage, oneLine } from "../format.js";
6
+ import { envNumber, errorMessage, oneLine } from "../format.js";
5
7
  import { initHordeDir } from "../ledger/files.js";
6
8
  import { resetMemberState } from "../member.js";
7
9
  import type { MemberState } from "../member.js";
@@ -9,17 +11,26 @@ import { hardStopMembers, requestCleanStop } from "./controls.js";
9
11
  import { finalizeRun, stopRunTimers } from "./finalize.js";
10
12
  import { recordLifecycle, recordPhase } from "./lifecycle.js";
11
13
  import { createRun, seedRun } from "./setup.js";
14
+ import type { RunSpec } from "./setup.js";
12
15
  import { drainRepairs } from "./repair.js";
13
16
  import { startCollaborationScheduler } from "./schedule.js";
14
17
  import { startSolvedWatch } from "./solved.js";
15
18
  import { spawnOne, startStallWatch, superviseMember } from "./supervise.js";
16
19
  import type { RunLaunch } from "./supervise.js";
17
- import type { HordeRun, HordeState } from "./types.js";
20
+ import { allocateSwarm, dealLayers, siblingHordes, swarmMarkdown } from "./swarm.js";
21
+ import type { SwarmLayout } from "./swarm.js";
22
+ import { noteRunFinalized, noteRunReported, writeSwarmRecord } from "./swarm-finalize.js";
23
+ import { runPlanner } from "./synthesis.js";
24
+ import { activeRuns, finalizingRuns, hordeLabel } from "./types.js";
25
+ import type { HordeRun, HordeState, Swarm } from "./types.js";
18
26
  import { mountWidget, safeNotify, safeNotifyUi, unmountWidget } from "./ui.js";
19
27
 
20
28
  /** How long the canary preflight waits for the first token before declaring failure. */
21
29
  const CANARY_TIMEOUT_MS = Number(process.env.PI_HORDE_CANARY_MS) || 60_000;
22
30
 
31
+ /** Pause between one horde's launch and the next, so H canaries do not hit the provider at once. */
32
+ const SWARM_STAGGER_MS = envNumber("PI_HORDE_SWARM_STAGGER_MS", 5_000);
33
+
23
34
  type CanaryOutcome = "output" | "exit" | "timeout";
24
35
 
25
36
  function sleep(ms: number): Promise<void> {
@@ -53,7 +64,15 @@ async function ensureDashboard(
53
64
  try {
54
65
  state.dashboard = await startDashboard({
55
66
  port: resolveDashboardPort(option.port),
56
- source: { hordeDir, getActive: () => state.active, getFinalizing: () => state.finalizing },
67
+ source: {
68
+ hordeDir,
69
+ getActive: () => (state.active ? activeRuns(state.active) : []),
70
+ getFinalizing: () => [
71
+ ...(state.active ? finalizingRuns(state.active) : []),
72
+ ...(state.finalizing ? finalizingRuns(state.finalizing) : []),
73
+ ],
74
+ getSwarm: () => state.active ?? state.finalizing,
75
+ },
57
76
  onError: (error) => {
58
77
  state.dashboard = undefined;
59
78
  safeNotifyUi(ctx, `dashboard error: ${error.message}; the run continues`, "error");
@@ -143,13 +162,18 @@ async function runCanary(launch: RunLaunch, spawns: Promise<void>[]): Promise<Ca
143
162
  * One retry distinguishes a flaky process from a dead provider.
144
163
  */
145
164
  async function preflightAndFanOut(launch: RunLaunch): Promise<void> {
146
- const { state, run, ctx } = launch;
165
+ const { state, swarm, run, ctx } = launch;
166
+ if (run.stopped) {
167
+ // A stop arrived while this horde waited its turn: nothing to spawn, finalize now.
168
+ await finalizeRun(state, swarm, run);
169
+ return;
170
+ }
147
171
  const spawns: Promise<void>[] = [];
148
172
  const outcome = await runCanary(launch, spawns);
149
173
  if (run.stopped) {
150
174
  // A stop arrived during preflight: do not fan out, finalize now.
151
175
  await Promise.all(spawns);
152
- await finalizeRun(state, run);
176
+ await finalizeRun(state, swarm, run);
153
177
  return;
154
178
  }
155
179
  if (outcome === "output") {
@@ -175,48 +199,169 @@ async function preflightAndFanOut(launch: RunLaunch): Promise<void> {
175
199
  if (run.scheduleTimer) clearInterval(run.scheduleTimer);
176
200
  run.scheduleTimer = undefined;
177
201
  await drainRepairs(launch);
178
- await finalizeRun(state, run);
202
+ await finalizeRun(state, swarm, run);
203
+ }
204
+
205
+ /** The hordes' workstreams and plans: dealt layers, the planner's slices, or nothing for one horde. */
206
+ interface Partition {
207
+ slices: string[] | undefined;
208
+ /** Per-horde plan layers, indexed by horde - 1. */
209
+ plans: Array<string[] | undefined>;
210
+ }
211
+
212
+ /** Note the planner's failure where the dashboard and the operator can find it. */
213
+ function writeNotStarted(layout: SwarmLayout, reason: string): void {
214
+ if (!layout.dir) return;
215
+ fs.writeFileSync(path.join(layout.dir, "SWARM.md"), `# Swarm ${layout.id}\n\nNot started: ${reason}\n`, "utf-8");
216
+ }
217
+
218
+ /**
219
+ * Decide what each horde works on. `plan` layers go round-robin to the hordes and each horde
220
+ * runs its share as its own plan; a plain prompt is split by the planner helper, and a planner
221
+ * that cannot produce H distinct workstreams stops the launch rather than running H hordes on
222
+ * one slice.
223
+ */
224
+ async function partitionTask(
225
+ state: HordeState,
226
+ ctx: ExtensionCommandContext,
227
+ parsed: HordeRunArgs,
228
+ layout: SwarmLayout,
229
+ ): Promise<Partition | undefined> {
230
+ if (parsed.hordes === 1) return { slices: undefined, plans: [parsed.plan] };
231
+ if (parsed.plan) {
232
+ const dealt = dealLayers(parsed.plan, parsed.hordes);
233
+ return { slices: dealt.map((layers) => layers.join("; ")), plans: dealt };
234
+ }
235
+ ctx.ui.notify(`swarm ${layout.id}: planner is partitioning the task into ${parsed.hordes} workstreams`, "info");
236
+ const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
237
+ const result = await runPlanner(state, { dir: layout.dir!, cwd: ctx.cwd, model, prompt: parsed.prompt, hordes: parsed.hordes });
238
+ if ("error" in result) {
239
+ writeNotStarted(layout, result.error);
240
+ ctx.ui.notify(`swarm ${layout.id} not started: ${result.error}`, "error");
241
+ return undefined;
242
+ }
243
+ return { slices: result.slices, plans: Array.from({ length: parsed.hordes }, () => undefined) };
244
+ }
245
+
246
+ function buildSwarm(
247
+ ctx: ExtensionCommandContext,
248
+ parsed: HordeRunArgs,
249
+ layout: SwarmLayout,
250
+ partition: Partition,
251
+ dashboardUrl: string | undefined,
252
+ ): { swarm: Swarm; launches: Array<{ run: HordeRun; roles: string[] }> } {
253
+ const launches = layout.runIds.map((id, index) => {
254
+ const spec: RunSpec = {
255
+ id,
256
+ hordeDir: layout.hordeDirs[index]!,
257
+ runDir: layout.runDirs[index]!,
258
+ horde: index + 1,
259
+ hordes: parsed.hordes,
260
+ swarmId: layout.id,
261
+ slice: partition.slices?.[index],
262
+ plan: partition.plans[index],
263
+ siblings: siblingHordes(layout, partition.slices, index + 1),
264
+ };
265
+ return createRun(ctx, parsed, spec, dashboardUrl);
266
+ });
267
+ const swarm: Swarm = {
268
+ id: layout.id,
269
+ dir: layout.dir,
270
+ hordeDir: layout.hordeDirs.length === 1 ? layout.hordeDirs[0]! : path.resolve(layout.dir!, "..", ".."),
271
+ cwd: ctx.cwd,
272
+ prompt: parsed.prompt,
273
+ hordes: parsed.hordes,
274
+ count: parsed.count,
275
+ startedAt: Date.now(),
276
+ runs: launches.map((launch) => launch.run),
277
+ plan: parsed.plan,
278
+ partition: partition.slices,
279
+ untilStop: parsed.untilStop === true ? true : undefined,
280
+ dashboardUrl,
281
+ };
282
+ return { swarm, launches };
283
+ }
284
+
285
+ /** Seed one horde's ledgers and record its start; throws on a ledger failure. */
286
+ async function seedHorde(swarm: Swarm, run: HordeRun, roles: string[]): Promise<void> {
287
+ await seedRun(run);
288
+ await recordLifecycle(run, "run-start", {
289
+ count: run.count, model: run.model, verifier: run.verifier, plan: run.plan, untilStop: run.untilStop,
290
+ roles: roles.map((role) => oneLine(role, 120)), dashboardUrl: run.dashboardUrl,
291
+ ...(swarm.hordes > 1 ? { swarm: swarm.id, horde: run.horde, hordes: swarm.hordes, slice: run.slice } : {}),
292
+ });
293
+ await recordPhase(run, "discovery", "start");
294
+ }
295
+
296
+ function announceStart(ctx: ExtensionCommandContext, swarm: Swarm): void {
297
+ if (swarm.hordes === 1) {
298
+ const run = swarm.runs[0]!;
299
+ const plural = run.count === 1 ? "" : "s";
300
+ ctx.ui.notify(`horde ${run.id} started with ${run.count} member${plural} in ${run.hordeDir}`, "info");
301
+ if (run.dashboardUrl) ctx.ui.notify(`horde ${run.id} dashboard: ${run.dashboardUrl}runs/${run.id}`, "info");
302
+ return;
303
+ }
304
+ ctx.ui.notify(
305
+ `swarm ${swarm.id} started: ${swarm.hordes} hordes of ${swarm.count} members in ${swarm.dir}, ` +
306
+ `${Math.round(SWARM_STAGGER_MS / 1000)}s apart`,
307
+ "info",
308
+ );
309
+ for (const [index, slice] of (swarm.partition ?? []).entries()) {
310
+ ctx.ui.notify(`${hordeLabel(index + 1)}: ${oneLine(slice, 160)}`, "info");
311
+ }
312
+ if (swarm.dashboardUrl) ctx.ui.notify(`swarm ${swarm.id} dashboard: ${swarm.dashboardUrl}swarms/${swarm.id}`, "info");
313
+ }
314
+
315
+ /** Start the hordes' preflights a few seconds apart; a stop during the wait is honoured. */
316
+ async function staggerLaunches(state: HordeState, launches: RunLaunch[]): Promise<void> {
317
+ for (const [index, launch] of launches.entries()) {
318
+ if (index > 0 && !state.disposed) await sleep(SWARM_STAGGER_MS);
319
+ if (state.disposed) return;
320
+ void preflightAndFanOut(launch).catch((error) => {
321
+ safeNotify(launch.run, `horde ${launch.run.id} failed: ${errorMessage(error)}`, "error");
322
+ // The horde is down for good; let the swarm account for it so the slot and widget free.
323
+ noteRunReported(state, launch.swarm, launch.run);
324
+ void noteRunFinalized(state, launch.swarm, launch.run);
325
+ });
326
+ }
179
327
  }
180
328
 
181
- async function launchRun(
329
+ async function launchSwarm(
182
330
  state: HordeState,
183
331
  ctx: ExtensionCommandContext,
184
332
  parsed: HordeRunArgs,
185
333
  hordeDir: string,
186
334
  ): Promise<void> {
187
- const { run, roles } = createRun(ctx, parsed, hordeDir, state.dashboard?.url);
188
- // Claim the run synchronously, before the first await, so a second
335
+ const layout = allocateSwarm(hordeDir, parsed.hordes, Date.now());
336
+ const partition = await partitionTask(state, ctx, parsed, layout);
337
+ if (!partition || state.disposed) return;
338
+ const { swarm, launches } = buildSwarm(ctx, parsed, layout, partition, state.dashboard?.url);
339
+ // Claim the slot synchronously, before the first await, so a second
189
340
  // /agent-horde invocation in the same tick sees `active` and is refused.
190
- state.active = run;
191
- if (ctx.hasUI) mountWidget(run, ctx.ui);
192
- startDeadline(run, ctx);
193
- startSolvedWatch(run, ctx);
341
+ state.active = swarm;
342
+ if (ctx.hasUI) mountWidget(swarm, ctx.ui);
343
+ for (const run of swarm.runs) {
344
+ startDeadline(run, ctx);
345
+ startSolvedWatch(run, ctx);
346
+ }
194
347
  try {
195
- await seedRun(run);
196
- await recordLifecycle(run, "run-start", {
197
- count: run.count, model: run.model, verifier: run.verifier, plan: run.plan, untilStop: run.untilStop,
198
- roles: roles.map((role) => oneLine(role, 120)), dashboardUrl: run.dashboardUrl,
199
- });
200
- await recordPhase(run, "discovery", "start");
348
+ if (swarm.dir) {
349
+ fs.writeFileSync(path.join(swarm.dir, "SWARM.md"), swarmMarkdown(swarm), "utf-8");
350
+ writeSwarmRecord(swarm);
351
+ }
352
+ for (const { run, roles } of launches) await seedHorde(swarm, run, roles);
201
353
  } catch (error) {
202
- stopRunTimers(run);
203
- unmountWidget(run);
204
- if (state.active === run) state.active = undefined;
205
- ctx.ui.notify(`horde ${run.id} failed to start: ${errorMessage(error)}`, "error");
354
+ for (const run of swarm.runs) stopRunTimers(run);
355
+ unmountWidget(swarm);
356
+ if (state.active === swarm) state.active = undefined;
357
+ ctx.ui.notify(`horde ${swarm.id} failed to start: ${errorMessage(error)}`, "error");
206
358
  return;
207
359
  }
208
- void preflightAndFanOut({ state, run, ctx, roles }).catch((error) => {
209
- safeNotify(run, `horde ${run.id} failed: ${errorMessage(error)}`, "error");
210
- if (state.active === run) state.active = undefined;
211
- });
212
- const plural = run.count === 1 ? "" : "s";
213
- ctx.ui.notify(`horde ${run.id} started with ${run.count} member${plural} in ${hordeDir}`, "info");
214
- if (run.dashboardUrl) {
215
- ctx.ui.notify(`horde ${run.id} dashboard: ${run.dashboardUrl}runs/${run.id}`, "info");
216
- }
360
+ void staggerLaunches(state, launches.map(({ run, roles }) => ({ state, swarm, run, ctx, roles })));
361
+ announceStart(ctx, swarm);
217
362
  }
218
363
 
219
- export async function startRun(
364
+ export async function startSwarm(
220
365
  state: HordeState,
221
366
  ctx: ExtensionCommandContext,
222
367
  parsed: HordeRunArgs,
@@ -225,7 +370,7 @@ export async function startRun(
225
370
  try {
226
371
  const hordeDir = initHordeDir(ctx.cwd);
227
372
  if (!(await ensureDashboard(state, ctx, hordeDir, parsed.dashboard))) return;
228
- await launchRun(state, ctx, parsed, hordeDir);
373
+ await launchSwarm(state, ctx, parsed, hordeDir);
229
374
  } finally {
230
375
  state.starting = false;
231
376
  }
package/src/run/setup.ts CHANGED
@@ -4,15 +4,31 @@ import { createHash } from "node:crypto";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
6
  import type { HordeRunArgs } from "../args.js";
7
- import { formatRunId } from "../format.js";
8
7
  import { appendRunHeading } from "../ledger/board.js";
9
8
  import { listFacts } from "../ledger/facts.js";
10
9
  import { ensureFindingIds } from "../ledger/findings.js";
11
10
  import { claimIdea, listIdeas, seedPlan } from "../ledger/ideas.js";
12
11
  import { newMemberState } from "../member.js";
12
+ import type { SiblingHorde } from "../member.js";
13
13
  import { VERIFIER_ROLE, memberSystemPrompt, taskMarkdown } from "../prompt.js";
14
14
  import type { HordeRun } from "./types.js";
15
15
 
16
+ /** Where one horde of a swarm runs and what it was given. */
17
+ export interface RunSpec {
18
+ id: string;
19
+ hordeDir: string;
20
+ runDir: string;
21
+ /** 1-based horde index in the swarm. */
22
+ horde: number;
23
+ hordes: number;
24
+ swarmId: string;
25
+ /** The horde's workstream when the swarm partitioned the task. */
26
+ slice?: string | undefined;
27
+ /** The `plan` layers this horde runs as its own plan. */
28
+ plan?: string[] | undefined;
29
+ siblings: SiblingHorde[];
30
+ }
31
+
16
32
  function planMarkdown(id: string, layers: string[], count: number): string {
17
33
  return [
18
34
  `# Plan ${id}`,
@@ -55,32 +71,17 @@ function defaultRoles(count: number, noVerifier: boolean): string[] {
55
71
  return roles;
56
72
  }
57
73
 
58
- function allocateRunDir(hordeDir: string, startedAt: number): { id: string; runDir: string } {
59
- const base = formatRunId(new Date(startedAt));
60
- for (let attempt = 0; ; attempt++) {
61
- const id = attempt === 0 ? base : `${base}-${attempt}`;
62
- const runDir = path.join(hordeDir, "runs", id);
63
- try {
64
- fs.mkdirSync(runDir);
65
- } catch (error) {
66
- if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
67
- throw error;
68
- }
69
- fs.mkdirSync(path.join(runDir, "sessions"), { recursive: true });
70
- return { id, runDir };
71
- }
72
- }
73
-
74
- /** Allocate the run directory, write TASK.md and SYSTEM.md, and build the run record. */
74
+ /** Write TASK.md and SYSTEM.md into the allocated run directory and build the run record. */
75
75
  export function createRun(
76
76
  ctx: ExtensionCommandContext,
77
77
  parsed: HordeRunArgs,
78
- hordeDir: string,
78
+ spec: RunSpec,
79
79
  dashboardUrl: string | undefined,
80
80
  ): { run: HordeRun; roles: string[] } {
81
- const { count, prompt, plan } = parsed;
81
+ const { count, prompt } = parsed;
82
+ const { id, hordeDir, runDir, plan } = spec;
82
83
  const startedAt = Date.now();
83
- const { id, runDir } = allocateRunDir(hordeDir, startedAt);
84
+ fs.mkdirSync(path.join(runDir, "sessions"), { recursive: true });
84
85
  const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
85
86
  const roles =
86
87
  plan && plan.length > 0
@@ -88,18 +89,27 @@ export function createRun(
88
89
  : defaultRoles(count, parsed.noVerifier === true);
89
90
  const verifierIndex = roles.indexOf(VERIFIER_ROLE);
90
91
  const verifier = verifierIndex === -1 ? undefined : `member-${verifierIndex + 1}`;
92
+ const swarm = spec.hordes > 1 ? { id: spec.swarmId, horde: spec.horde, hordes: spec.hordes, slice: spec.slice } : undefined;
91
93
  fs.writeFileSync(
92
94
  path.join(runDir, "TASK.md"),
93
95
  taskMarkdown({
94
- id, prompt, count, model, thinkingLevel: ctx.thinkingLevel, startedAt, verifier, untilStop: parsed.untilStop,
96
+ id, prompt, count, model, thinkingLevel: ctx.thinkingLevel, startedAt, verifier, untilStop: parsed.untilStop, swarm,
95
97
  }),
96
98
  "utf-8",
97
99
  );
98
- fs.writeFileSync(path.join(runDir, "SYSTEM.md"), memberSystemPrompt({ hordeDir, count }), "utf-8");
100
+ fs.writeFileSync(
101
+ path.join(runDir, "SYSTEM.md"),
102
+ memberSystemPrompt({ hordeDir, count, siblings: spec.siblings }),
103
+ "utf-8",
104
+ );
99
105
  fs.mkdirSync(path.join(runDir, "artifacts"), { recursive: true });
100
- writeManifest(runDir, { model, thinking: ctx.thinkingLevel, roles, prompt });
106
+ writeManifest(runDir, { model, thinking: ctx.thinkingLevel, roles, prompt, swarm });
101
107
  const run: HordeRun = {
102
108
  id, hordeDir, runDir, prompt, count, startedAt,
109
+ horde: spec.horde,
110
+ hordes: spec.hordes,
111
+ slice: spec.slice,
112
+ siblings: spec.siblings,
103
113
  members: Array.from({ length: count }, (_, i) => newMemberState(i + 1)),
104
114
  controller: new AbortController(),
105
115
  controllers: new Map(),
@@ -131,7 +141,7 @@ function writeManifest(runDir: string, settings: Record<string, unknown>): void
131
141
  }
132
142
  };
133
143
  visit(root);
134
- const config = Object.fromEntries(Object.entries(process.env).filter(([key]) => /^PI_HORDE_(?:REPAIR|REMIND|STALL|RESET|MAX|ALLOW|SOLVED|INBOX|CANARY|DELIVERABLE|POSTMORTEM|CLEANUP)/.test(key)));
144
+ const config = Object.fromEntries(Object.entries(process.env).filter(([key]) => /^PI_HORDE_(?:REPAIR|REMIND|STALL|RESET|MAX|ALLOW|SOLVED|INBOX|CANARY|DELIVERABLE|POSTMORTEM|CLEANUP|SWARM|PLANNER)/.test(key)));
135
145
  fs.writeFileSync(path.join(runDir, "MANIFEST.json"), JSON.stringify({ protocol: 1, codeHash: code.digest("hex"),
136
146
  promptHash: createHash("sha256").update(String(settings.prompt)).digest("hex"),
137
147
  systemHash: createHash("sha256").update(fs.readFileSync(path.join(runDir, "SYSTEM.md"))).digest("hex"), settings, config }, null, 2));
@@ -11,7 +11,7 @@ import { resetMemberState, spawnMember } from "../member.js";
11
11
  import type { MemberState } from "../member.js";
12
12
  import { hardStopMembers } from "./controls.js";
13
13
  import { recordLifecycle } from "./lifecycle.js";
14
- import type { HordeRun, HordeState } from "./types.js";
14
+ import type { HordeRun, HordeState, Swarm } from "./types.js";
15
15
  import { refreshRun, safeNotify } from "./ui.js";
16
16
 
17
17
  /** A running member with no ledger write for this long is reset; 0 disables. */
@@ -37,6 +37,8 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout
37
37
  /** What spawning and supervising a run's members needs beyond the run itself. */
38
38
  export interface RunLaunch {
39
39
  state: HordeState;
40
+ /** The swarm the run belongs to; a plain run is a swarm of one. */
41
+ swarm: Swarm;
40
42
  run: HordeRun;
41
43
  ctx: ExtensionCommandContext;
42
44
  /** Per-member role instruction, indexed by member index - 1. */
@@ -0,0 +1,121 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { errorMessage } from "../format.js";
4
+ import { SWARM_REPORT_TYPE, swarmHordeRows, swarmReportMarkdown, swarmReportText } from "./swarm-report.js";
5
+ import type { SwarmReportDetails } from "./swarm-report.js";
6
+ import { runSwarmDeliverable } from "./synthesis.js";
7
+ import type { HordeRun, HordeState, Swarm } from "./types.js";
8
+ import { refreshSwarm, safeNotify, unmountWidget } from "./ui.js";
9
+
10
+ /** `SWARM.json`: the swarm's timeline for the dashboard once nothing is in memory. */
11
+ export interface SwarmRecord {
12
+ id: string;
13
+ prompt: string;
14
+ hordes: number;
15
+ count: number;
16
+ startedAt: number;
17
+ untilStop: boolean;
18
+ partition: string[];
19
+ runs: Array<{ label: string; runId: string; runDir: string; hordeDir: string; startedAt: number; endedAt?: number | undefined; outcome?: string | undefined }>;
20
+ /** The merge step: when the swarm deliverable helper ran and whether it wrote anything. */
21
+ merge?: { startedAt: number; endedAt?: number | undefined; written?: boolean | undefined } | undefined;
22
+ endedAt?: number | undefined;
23
+ }
24
+
25
+ export function swarmRecord(swarm: Swarm): SwarmRecord {
26
+ return {
27
+ id: swarm.id,
28
+ prompt: swarm.prompt,
29
+ hordes: swarm.hordes,
30
+ count: swarm.count,
31
+ startedAt: swarm.startedAt,
32
+ untilStop: swarm.untilStop === true,
33
+ partition: swarm.partition ?? [],
34
+ runs: swarm.runs.map((run) => ({
35
+ label: `h${run.horde}`,
36
+ runId: run.id,
37
+ runDir: run.runDir,
38
+ hordeDir: run.hordeDir,
39
+ startedAt: run.startedAt,
40
+ endedAt: run.finalized ? Math.max(run.startedAt, ...run.members.map((m) => m.endedAt ?? 0)) : undefined,
41
+ outcome: run.outcome,
42
+ })),
43
+ };
44
+ }
45
+
46
+ /** Write a swarm file, reporting (not throwing) a failure so finalize keeps going. */
47
+ function writeSwarmFile(swarm: Swarm, name: string, content: string): void {
48
+ if (!swarm.dir) return;
49
+ try {
50
+ fs.writeFileSync(path.join(swarm.dir, name), content, "utf-8");
51
+ } catch (error) {
52
+ safeNotify(swarm, `swarm ${swarm.id}: could not write ${name} (${errorMessage(error)})`, "error");
53
+ }
54
+ }
55
+
56
+ export function writeSwarmRecord(swarm: Swarm, extra: Partial<SwarmRecord> = {}): void {
57
+ writeSwarmFile(swarm, "SWARM.json", JSON.stringify({ ...swarmRecord(swarm), ...extra }, null, 2));
58
+ }
59
+
60
+ /**
61
+ * A horde's members exited and its REPORT.md is written. Once every horde is there the swarm
62
+ * slot frees, so a new launch can start while the (possibly slow) synthesis steps run.
63
+ */
64
+ export function noteRunReported(state: HordeState, swarm: Swarm, run: HordeRun): void {
65
+ run.reported = true;
66
+ if (!swarm.runs.every((other) => other.reported === true)) return;
67
+ if (state.active === swarm) state.active = undefined;
68
+ state.finalizing = swarm;
69
+ }
70
+
71
+ /** The merged deliverable, REPORT.md, and the injected message of a swarm of several hordes. */
72
+ async function mergeSwarm(state: HordeState, swarm: Swarm): Promise<void> {
73
+ swarm.finalizing = ["merge"];
74
+ refreshSwarm(swarm);
75
+ const merge: NonNullable<SwarmRecord["merge"]> = { startedAt: Date.now() };
76
+ writeSwarmRecord(swarm, { merge });
77
+ swarm.deliverable = process.env.PI_HORDE_DELIVERABLE === "off" ? undefined : await runSwarmDeliverable(state, swarm);
78
+ if (swarm.deliverable) writeSwarmFile(swarm, "DELIVERABLE.md", swarm.deliverable);
79
+ const now = Date.now();
80
+ merge.endedAt = now;
81
+ merge.written = Boolean(swarm.deliverable);
82
+ writeSwarmFile(swarm, "REPORT.md", swarmReportMarkdown(swarm, now));
83
+ writeSwarmRecord(swarm, { merge, endedAt: now });
84
+ if (state.disposed) return;
85
+ const details: SwarmReportDetails = {
86
+ swarmId: swarm.id,
87
+ elapsedMs: now - swarm.startedAt,
88
+ hordes: swarmHordeRows(swarm, now),
89
+ deliverable: swarm.deliverable,
90
+ };
91
+ const content = [swarmReportText(swarm, now), ...(swarm.deliverable ? ["## Deliverable", "", swarm.deliverable] : [])].join("\n\n");
92
+ // A hard stop of every horde keeps the parent idle, as it does for one horde.
93
+ const triggerTurn = swarm.runs.some((run) => run.cleanStopNames.length > 0 || !run.stopped);
94
+ state.pi.sendMessage(
95
+ { customType: SWARM_REPORT_TYPE, content, display: true, details },
96
+ { deliverAs: "followUp", triggerTurn },
97
+ );
98
+ safeNotify(swarm, `swarm ${swarm.id} finished`, "info");
99
+ }
100
+
101
+ /**
102
+ * A horde's finalize completed, synthesis included. The swarm step runs once every horde is
103
+ * done: a swarm of one has nothing to merge and only releases the widget.
104
+ */
105
+ export async function noteRunFinalized(state: HordeState, swarm: Swarm, run: HordeRun): Promise<void> {
106
+ run.finalized = true;
107
+ if (!swarm.runs.every((other) => other.finalized === true)) {
108
+ refreshSwarm(swarm);
109
+ return;
110
+ }
111
+ try {
112
+ if (swarm.dir && !state.disposed) await mergeSwarm(state, swarm);
113
+ } catch (error) {
114
+ safeNotify(swarm, `swarm ${swarm.id} failed to finalize: ${errorMessage(error)}`, "error");
115
+ } finally {
116
+ swarm.finalizing = undefined;
117
+ if (state.active === swarm) state.active = undefined;
118
+ if (state.finalizing === swarm) state.finalizing = undefined;
119
+ unmountWidget(swarm);
120
+ }
121
+ }