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/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/repair.ts CHANGED
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { inboxFor } from "../tool/inbox.js";
5
5
  import type { PeerMessage } from "../ledger/collaboration.js";
6
6
  import { recordEvent } from "../ledger/events.js";
7
+ import type { EventContext } from "../ledger/events.js";
7
8
  import { recordPhase } from "./lifecycle.js";
8
9
  import { envNumber } from "../format.js";
9
10
  import { resetMemberState } from "../member.js";
@@ -24,11 +25,94 @@ function repairPriority(message: PeerMessage): number {
24
25
  return 3;
25
26
  }
26
27
 
27
- function queueFor(launch: RunLaunch, member: MemberState): PeerMessage[] {
28
- return inboxFor({ hordeDir: launch.run.hordeDir, runId: launch.run.id, actor: member.name })
28
+ /** The repair phase's scheduling state, shared by every round. */
29
+ interface RepairPhase {
30
+ launch: RunLaunch;
31
+ floors: RepairFloors;
32
+ /**
33
+ * Threads a member's completed attempts left exactly as they found them, by member then
34
+ * message id: the signature the thread was stuck at and how many consecutive attempts left it
35
+ * there. The thread is held back from that member once the count reaches `UNMOVED_ATTEMPTS`,
36
+ * until its signature changes, which only another member's action or a new response can cause.
37
+ */
38
+ stalled: Map<string, Map<string, UnmovedThread>>;
39
+ }
40
+
41
+ interface UnmovedThread {
42
+ signature: string;
43
+ attempts: number;
44
+ }
45
+
46
+ /**
47
+ * Completed attempts that may leave a thread untouched before it is held. One miss is routine: a
48
+ * member that answered the rest of its queue, or re-answered the task, often clears the thread on
49
+ * its next attempt within seconds. Two misses on the same signature are the loop this guards.
50
+ */
51
+ const UNMOVED_ATTEMPTS = 2;
52
+
53
+ function memberContext(phase: RepairPhase, member: MemberState): EventContext {
54
+ return { hordeDir: phase.launch.run.hordeDir, runId: phase.launch.run.id, actor: member.name };
55
+ }
56
+
57
+ /**
58
+ * What an attempt can change on a thread: its state and the revision it was answered with.
59
+ * Delivery receipts and a re-worded response on the same revision are not progress, so neither
60
+ * the inbox read of a fresh attempt nor a recipient repeating itself re-qualifies a thread.
61
+ */
62
+ function threadSignature(m: PeerMessage): string {
63
+ return `${m.state}:${m.responseRevision ?? ""}:${m.responseTarget ?? ""}`;
64
+ }
65
+
66
+ function isStalled(phase: RepairPhase, member: MemberState, m: PeerMessage): boolean {
67
+ const unmoved = phase.stalled.get(member.name)?.get(m.id);
68
+ return unmoved !== undefined && unmoved.signature === threadSignature(m) && unmoved.attempts >= UNMOVED_ATTEMPTS;
69
+ }
70
+
71
+ /** The threads currently held back from the member, in inbox order. */
72
+ function heldThreads(phase: RepairPhase, member: MemberState): string[] {
73
+ return inboxFor(memberContext(phase, member)).filter((m) => isStalled(phase, member, m)).map((m) => m.id);
74
+ }
75
+
76
+ /** The threads an attempt for the member would be handed: its inbox minus the threads held back. */
77
+ function queueFor(phase: RepairPhase, member: MemberState): PeerMessage[] {
78
+ return inboxFor(memberContext(phase, member)).filter((m) => !isStalled(phase, member, m))
29
79
  .sort((a, b) => repairPriority(a) - repairPriority(b) || a.createdAt.localeCompare(b.createdAt));
30
80
  }
31
81
 
82
+ /**
83
+ * Counts, per thread, the consecutive completed attempts that left it as they found it; a thread
84
+ * the attempt moved starts over. A killed attempt counts nothing: it may have been about to act.
85
+ * Returns the ids this attempt left unchanged.
86
+ */
87
+ function noteUnchanged(phase: RepairPhase, member: MemberState, queue: PeerMessage[]): string[] {
88
+ if (member.endCause !== "completed") return [];
89
+ const after = new Map(inboxFor(memberContext(phase, member)).map((m) => [m.id, threadSignature(m)]));
90
+ const unmoved = phase.stalled.get(member.name) ?? new Map<string, UnmovedThread>();
91
+ const unchanged: string[] = [];
92
+ for (const m of queue) {
93
+ const signature = threadSignature(m);
94
+ if (after.get(m.id) !== signature) { unmoved.delete(m.id); continue; }
95
+ const previous = unmoved.get(m.id);
96
+ unmoved.set(m.id, { signature, attempts: previous?.signature === signature ? previous.attempts + 1 : 1 });
97
+ unchanged.push(m.id);
98
+ }
99
+ phase.stalled.set(member.name, unmoved);
100
+ return unchanged;
101
+ }
102
+
103
+ /**
104
+ * Once every thread in the member's inbox is one its own completed attempt could not move, later
105
+ * rounds skip the member and its threads drop out of the demand that sizes the shares, until
106
+ * another member changes one. Recorded so the post-mortem sees why the member stopped appearing.
107
+ */
108
+ async function recordStalled(phase: RepairPhase, member: MemberState): Promise<void> {
109
+ const held = heldThreads(phase, member);
110
+ if (!held.length || queueFor(phase, member).length) return;
111
+ const { run } = phase.launch;
112
+ run.repairStalled = [...new Set([...(run.repairStalled ?? []), member.name])];
113
+ await recordEvent({ ...memberContext(phase, member), actor: "parent", attempt: "scheduler" }, "repair-skipped", { payload: { reason: "no-progress", members: [{ member: member.name, messages: held }] } });
114
+ }
115
+
32
116
  /**
33
117
  * What a queue costs in floors: one cold session covers the administrative threads in it, and
34
118
  * each review request or blocking challenge is an inspect/attach/review cycle of about a floor
@@ -114,10 +198,10 @@ export function repairBudget(demands: number[], floors: RepairFloors, rounds: nu
114
198
 
115
199
  type SkipReason = "insufficient-budget" | "insufficient-time";
116
200
 
117
- async function recordSkipped(launch: RunLaunch, pending: MemberState[], reason: SkipReason, detail: Record<string, number>): Promise<void> {
118
- const { run } = launch;
201
+ async function recordSkipped(phase: RepairPhase, pending: MemberState[], reason: SkipReason, detail: Record<string, number>): Promise<void> {
202
+ const { run } = phase.launch;
119
203
  const ctx = { hordeDir: run.hordeDir, runId: run.id, actor: "parent", attempt: "scheduler" };
120
- const members = pending.map((member) => ({ member: member.name, messages: queueFor(launch, member).map((m) => m.id) }))
204
+ const members = pending.map((member) => ({ member: member.name, messages: queueFor(phase, member).map((m) => m.id) }))
121
205
  .filter((entry) => entry.messages.length);
122
206
  if (!members.length) return;
123
207
  run.repairSkipped = [...new Set([...(run.repairSkipped ?? []), ...members.map((entry) => entry.member)])];
@@ -143,10 +227,11 @@ interface FirstTurnCost {
143
227
  * share is the allowance recorded and the deadline is derived from it once, so the record cannot
144
228
  * read a millisecond under the floor the share was raised to.
145
229
  */
146
- async function repairMember(launch: RunLaunch, member: MemberState, ms: number, limits: RepairLimits, floors: RepairFloors): Promise<void> {
230
+ async function repairMember(phase: RepairPhase, member: MemberState, ms: number, limits: RepairLimits): Promise<void> {
231
+ const { launch, floors } = phase;
147
232
  const { run } = launch;
148
- const ctx = { hordeDir: run.hordeDir, runId: run.id, actor: member.name };
149
- const queue = queueFor(launch, member);
233
+ const ctx = memberContext(phase, member);
234
+ const queue = queueFor(phase, member);
150
235
  if (!queue.length || stopReason(launch)) return;
151
236
  const prior = { turns: member.turns, toolCalls: member.toolCalls, usage: { ...member.usage }, startedAt: member.startedAt };
152
237
  resetMemberState(member);
@@ -181,7 +266,8 @@ async function repairMember(launch: RunLaunch, member: MemberState, ms: number,
181
266
  const timeout = setTimeout(() => halt("time"), Math.max(1, deadline - Date.now()));
182
267
  const watchdog = setInterval(check, 100);
183
268
  try {
184
- await recordEvent({ ...ctx, attempt: member.attempt }, "repair-start", { payload: { messages: queue.map((m) => m.id), demand: repairDemand(queue), prior, allowance, floors } });
269
+ const held = heldThreads(phase, member);
270
+ await recordEvent({ ...ctx, attempt: member.attempt }, "repair-start", { payload: { messages: queue.map((m) => m.id), demand: repairDemand(queue), prior, allowance, floors, ...(held.length ? { held } : {}) } });
185
271
  if (stopReason(launch) || Date.now() >= deadline) {
186
272
  halt(stopReason(launch) ?? "time");
187
273
  member.status = "stopped";
@@ -191,10 +277,12 @@ async function repairMember(launch: RunLaunch, member: MemberState, ms: number,
191
277
  // Enforce a share on every usage update, rather than waiting for the next poll.
192
278
  await spawnOne(launch, member, undefined, check);
193
279
  }
280
+ const unchanged = noteUnchanged(phase, member, queue);
194
281
  await recordEvent({ ...ctx, attempt: member.attempt }, "repair-end", { payload: {
195
282
  cause: member.endCause, stopReason: member.repairStopReason, allowance, usage: member.usage, turns: member.turns, firstTurn,
196
- messages: queue.map((m) => m.id),
283
+ messages: queue.map((m) => m.id), unchanged,
197
284
  } });
285
+ if (unchanged.length) await recordStalled(phase, member);
198
286
  } finally {
199
287
  clearTimeout(timeout);
200
288
  clearInterval(watchdog);
@@ -223,19 +311,42 @@ export async function drainRepairs(launch: RunLaunch): Promise<void> {
223
311
  }
224
312
  }
225
313
 
226
- function eligibleMembers(launch: RunLaunch): Array<{ member: MemberState; queue: PeerMessage[] }> {
227
- return launch.run.members.map((member) => ({ member, queue: queueFor(launch, member) })).filter((entry) => entry.queue.length)
314
+ function eligibleMembers(phase: RepairPhase): Array<{ member: MemberState; queue: PeerMessage[] }> {
315
+ return phase.launch.run.members.map((member) => ({ member, queue: queueFor(phase, member) })).filter((entry) => entry.queue.length)
228
316
  .sort((a, b) => repairPriority(a.queue[0]!) - repairPriority(b.queue[0]!) ||
229
317
  (a.member.repairs ?? 0) - (b.member.repairs ?? 0) || a.queue[0]!.createdAt.localeCompare(b.queue[0]!.createdAt));
230
318
  }
231
319
 
320
+ /** The queue a member was handed this round, by message id, at the signature it was handed. */
321
+ type HandedQueues = Map<string, Map<string, string>>;
322
+
323
+ /**
324
+ * True when every blocking thread left belongs to a member already served this round, and one of
325
+ * them is new or moved since that member was handed its queue. A correction answered mid-round
326
+ * leaves its requester to recheck it; finishing the round on administrative rechecks first spent
327
+ * the clock that recheck needed, so when a later round exists it starts now. A blocker the member
328
+ * left as it was handed is no reason to serve it again early; the last round serves everyone.
329
+ */
330
+ export function blockedOnServed(eligible: Array<{ member: MemberState; queue: PeerMessage[] }>, handed: HandedQueues): boolean {
331
+ let moved = false;
332
+ for (const { member, queue } of eligible) {
333
+ const seen = handed.get(member.name);
334
+ for (const m of queue) {
335
+ if (repairPriority(m) !== 0) continue;
336
+ if (!seen) return false;
337
+ if (seen.get(m.id) !== threadSignature(m)) moved = true;
338
+ }
339
+ }
340
+ return moved;
341
+ }
342
+
232
343
  async function runRepairRounds(launch: RunLaunch, begin: (budget: RepairBudget) => Promise<void>): Promise<void> {
233
344
  const { run } = launch;
234
345
  const rounds = Math.max(0, Math.min(6, envNumber("PI_HORDE_REPAIR_ROUNDS", 4)));
235
346
  if (rounds === 0 || stopReason(launch)) return;
236
- const floors = repairFloors();
347
+ const phase: RepairPhase = { launch, floors: repairFloors(), stalled: new Map() };
237
348
  await scheduleCollaboration(launch, { settleMs: 0, liveOnly: false });
238
- const budget = repairBudget(eligibleMembers(launch).map((entry) => repairDemand(entry.queue)), floors, rounds);
349
+ const budget = repairBudget(eligibleMembers(phase).map((entry) => repairDemand(entry.queue)), phase.floors, rounds);
239
350
  if (budget.queued === 0) return;
240
351
  const deadline = Date.now() + budget.ms;
241
352
  const exhausted = () => stopReason(launch) || Date.now() >= deadline || (run.repairTokens ?? 0) >= budget.tokens;
@@ -245,34 +356,35 @@ async function runRepairRounds(launch: RunLaunch, begin: (budget: RepairBudget)
245
356
  // One attempt per member per round, picked in priority order re-read before each attempt:
246
357
  // a thread that became blocking during an attempt moves its recipient ahead of the
247
358
  // administrative queues that were ordered first at round start.
248
- const served = new Set<string>();
359
+ const handed: HandedQueues = new Map();
249
360
  for (;;) {
250
361
  if (exhausted()) return;
251
- const pending = eligibleMembers(launch).filter((entry) => !served.has(entry.member.name));
252
- if (!pending.length) break;
362
+ const eligible = eligibleMembers(phase);
363
+ const pending = eligible.filter((entry) => !handed.has(entry.member.name));
364
+ if (!pending.length || (round < rounds - 1 && blockedOnServed(eligible, handed))) break;
253
365
  const next = pending[0]!;
254
366
  const pendingUnits = pending.reduce((sum, entry) => sum + repairDemand(entry.queue), 0);
255
367
  const units = repairDemand(next.queue);
256
368
  const unserved = pending.map((entry) => entry.member);
257
369
  const remaining = budget.tokens - (run.repairTokens ?? 0);
258
370
  const laterRounds = rounds - round - 1;
259
- const tokens = repairAllowance(repairShare(remaining, pendingUnits, laterRounds, units), remaining, floors.tokens);
371
+ const tokens = repairAllowance(repairShare(remaining, pendingUnits, laterRounds, units), remaining, phase.floors.tokens);
260
372
  if (tokens === undefined) {
261
373
  await begin(budget);
262
- await recordSkipped(launch, unserved, "insufficient-budget", { remaining, floor: floors.tokens });
374
+ await recordSkipped(phase, unserved, "insufficient-budget", { remaining, floor: phase.floors.tokens });
263
375
  return;
264
376
  }
265
377
  const remainingMs = Math.max(0, deadline - Date.now());
266
- const fundable = Math.floor(remaining / Math.max(1, floors.tokens));
267
- const ms = repairAllowance(repairTimeShare(remainingMs, pendingUnits, laterRounds, fundable, units), remainingMs, floors.ms);
378
+ const fundable = Math.floor(remaining / Math.max(1, phase.floors.tokens));
379
+ const ms = repairAllowance(repairTimeShare(remainingMs, pendingUnits, laterRounds, fundable, units), remainingMs, phase.floors.ms);
268
380
  if (ms === undefined || ms < 1) {
269
381
  await begin(budget);
270
- await recordSkipped(launch, unserved, "insufficient-time", { remainingMs, floorMs: floors.ms });
382
+ await recordSkipped(phase, unserved, "insufficient-time", { remainingMs, floorMs: phase.floors.ms });
271
383
  return;
272
384
  }
273
385
  await begin(budget);
274
- served.add(next.member.name);
275
- await repairMember(launch, next.member, ms, { tokens, turns: budget.turns }, floors);
386
+ handed.set(next.member.name, new Map(next.queue.map((m) => [m.id, threadSignature(m)])));
387
+ await repairMember(phase, next.member, ms, { tokens, turns: budget.turns });
276
388
  }
277
389
  }
278
390
  }
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));