agent-horde 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,
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
@@ -13,9 +13,12 @@ import { scheduleCollaboration, stopReason } from "./schedule.js";
13
13
  import { spawnOne } from "./supervise.js";
14
14
  import type { RunLaunch } from "./supervise.js";
15
15
 
16
- /** Blockers and independent checks take precedence over administrative exchanges. */
16
+ /**
17
+ * Blockers and independent checks take precedence over administrative exchanges. A request to
18
+ * validate the pending answer decides the run's outcome, so it ranks with the blockers.
19
+ */
17
20
  function repairPriority(message: PeerMessage): number {
18
- if (message.blocking) return 0;
21
+ if (message.blocking || message.target === "answer") return 0;
19
22
  if (message.kind === "review-request" && /^[AF]\d+$/.test(message.target)) return 1;
20
23
  if (message.state === "responded") return 2;
21
24
  return 3;
@@ -33,7 +36,7 @@ function queueFor(launch: RunLaunch, member: MemberState): PeerMessage[] {
33
36
  * three reviews demands three, so its share is sized to the work rather than to the member.
34
37
  */
35
38
  export function repairDemand(queue: PeerMessage[]): number {
36
- const checks = queue.filter((m) => m.blocking || (m.kind === "review-request" && /^[AF]\d+$/.test(m.target))).length;
39
+ const checks = queue.filter((m) => m.blocking || m.target === "answer" || (m.kind === "review-request" && /^[AF]\d+$/.test(m.target))).length;
37
40
  return Math.max(1, checks);
38
41
  }
39
42
 
@@ -135,8 +138,12 @@ interface FirstTurnCost {
135
138
  cacheWrite: number;
136
139
  }
137
140
 
138
- /** A finished worker receives a fresh, bounded attempt with a precise evidence handoff. */
139
- async function repairMember(launch: RunLaunch, member: MemberState, deadline: number, limits: RepairLimits, floors: RepairFloors): Promise<void> {
141
+ /**
142
+ * A finished worker receives a fresh, bounded attempt with a precise evidence handoff. The time
143
+ * share is the allowance recorded and the deadline is derived from it once, so the record cannot
144
+ * read a millisecond under the floor the share was raised to.
145
+ */
146
+ async function repairMember(launch: RunLaunch, member: MemberState, ms: number, limits: RepairLimits, floors: RepairFloors): Promise<void> {
140
147
  const { run } = launch;
141
148
  const ctx = { hordeDir: run.hordeDir, runId: run.id, actor: member.name };
142
149
  const queue = queueFor(launch, member);
@@ -146,7 +153,8 @@ async function repairMember(launch: RunLaunch, member: MemberState, deadline: nu
146
153
  member.repairs = (member.repairs ?? 0) + 1;
147
154
  member.repairStopReason = undefined;
148
155
  member.attempt = randomUUID();
149
- const allowance = { ...limits, ms: Math.max(0, deadline - Date.now()) };
156
+ const deadline = Date.now() + ms;
157
+ const allowance = { ...limits, ms };
150
158
  let firstTurn: FirstTurnCost | undefined;
151
159
  member.resetNote = `Repair attempt ${member.repairs}. Discovery is closed. Budget: about ${allowance.tokens} input+output tokens, ${allowance.turns} turns and ${allowance.ms}ms. ` +
152
160
  `Read horde inbox and referenced evidence. Work in this priority order: ${queue.map((m) => `${m.id} ${m.target} (${m.state})`).join(", ")}. ` +
@@ -264,7 +272,7 @@ async function runRepairRounds(launch: RunLaunch, begin: (budget: RepairBudget)
264
272
  }
265
273
  await begin(budget);
266
274
  served.add(next.member.name);
267
- await repairMember(launch, next.member, Date.now() + ms, { tokens, turns: budget.turns }, floors);
275
+ await repairMember(launch, next.member, ms, { tokens, turns: budget.turns }, floors);
268
276
  }
269
277
  }
270
278
  }
@@ -1,7 +1,7 @@
1
1
  import { collaborationState, openMessages } from "../ledger/collaboration.js";
2
2
  import type { ArtifactRecord, CollaborationState } from "../ledger/collaboration.js";
3
3
  import { runCollaborationAction } from "../tool/collaboration-actions.js";
4
- import { currentTarget } from "../tool/collaboration-target.js";
4
+ import { currentTarget, targetInfo } from "../tool/collaboration-target.js";
5
5
  import type { MemberState } from "../member.js";
6
6
  import type { RunLaunch } from "./supervise.js";
7
7
  import { safeNotify } from "./ui.js";
@@ -24,12 +24,17 @@ interface ScheduleOptions {
24
24
  liveOnly: boolean;
25
25
  }
26
26
 
27
- /** The non-owner with the fewest open review requests; ties go to the verifier, then roster order. */
28
- function pickReviewer(launch: RunLaunch, load: Map<string, number>, owner: string, liveOnly: boolean): MemberState | undefined {
27
+ /**
28
+ * A member that reviewed an earlier revision can reattest a behaviour-neutral change cheaply, so
29
+ * it goes first; otherwise the non-owner with the fewest open review requests, ties to the
30
+ * verifier, then roster order.
31
+ */
32
+ function pickReviewer(launch: RunLaunch, load: Map<string, number>, owner: string, liveOnly: boolean, prior: Set<string>): MemberState | undefined {
29
33
  const { run } = launch;
30
34
  const count = (member: MemberState) => load.get(member.name) ?? 0;
31
35
  const candidates = run.members.filter((m) => m.name !== owner && (!liveOnly || m.status === "running"));
32
- return candidates.sort((a, b) => count(a) - count(b) || Number(b.name === run.verifier) - Number(a.name === run.verifier))[0];
36
+ return candidates.sort((a, b) =>
37
+ Number(prior.has(b.name)) - Number(prior.has(a.name)) || count(a) - count(b) || Number(b.name === run.verifier) - Number(a.name === run.verifier))[0];
33
38
  }
34
39
 
35
40
  function reviewLoad(state: CollaborationState): Map<string, number> {
@@ -53,12 +58,17 @@ async function assignArtifactReviews(launch: RunLaunch, options: ScheduleOptions
53
58
  if (!settled(artifact, options.settleMs)) continue;
54
59
  if (state.reviews.some((r) => r.target === artifact.id && r.revision === artifact.hash)) continue;
55
60
  if (state.messages.some((m) => m.target === artifact.id && m.revision === artifact.hash && m.kind === "review-request")) continue;
56
- const reviewer = pickReviewer(launch, load, artifact.owner, options.liveOnly);
61
+ const prior = new Set(state.reviews.filter((r) => r.target === artifact.id && r.actor !== artifact.owner).map((r) => r.actor));
62
+ const reviewer = pickReviewer(launch, load, artifact.owner, options.liveOnly, prior);
57
63
  if (!reviewer) continue;
64
+ const carry = prior.has(reviewer.name)
65
+ ? ` You reviewed an earlier revision${artifact.changeNote ? ` (owner's change note: ${artifact.changeNote})` : ""}: if what you checked is unchanged, reattest with evidence instead of repeating the review.`
66
+ : "";
58
67
  await runCollaborationAction(ctx, { action: "message", kind: "review-request", target: artifact.id, to: reviewer.name,
59
68
  text: `Independently check ${artifact.path}@${artifact.hash} (${artifact.kind ?? "runnable"}) against: ${artifact.criteria}. ` +
60
69
  `${artifact.kind === "document" ? "Full-scope sourced/derived review may establish document acceptance; this does not establish runtime reproduction." : "Runnable acceptance requires full independent tested/reproduced support."} ` +
61
- "Attach evidence and record a revision-scoped review. The review closes this parent assignment; a failed/partial check opens a challenge to the owner.",
70
+ "Attach evidence and record a revision-scoped review: unchecked names in-criteria gaps, limits names what lies outside the criteria. " +
71
+ `The review closes this parent assignment; a refuting check opens a challenge to the owner.${carry}`,
62
72
  requestId: `review:${artifact.id}:${artifact.hash}` });
63
73
  load.set(reviewer.name, (load.get(reviewer.name) ?? 0) + 1);
64
74
  }
@@ -74,10 +84,23 @@ function successorOf(ctx: { hordeDir: string; runId: string; actor: string }, ta
74
84
  }
75
85
  }
76
86
 
87
+ /** A pinned dependency whose current revision moved on, with the owner's change note when it is an artifact. */
88
+ function revisionDrift(ctx: { hordeDir: string; runId: string; actor: string }, state: CollaborationState, dependency: ArtifactRecord["dependencies"][number]): string | undefined {
89
+ try {
90
+ const current = targetInfo(ctx, state, dependency.target).revision;
91
+ if (current === dependency.revision) return undefined;
92
+ const note = state.artifacts.find((a) => a.id === dependency.target)?.changeNote;
93
+ return `${dependency.target}@${dependency.revision.slice(0, 12)} -> @${current.slice(0, 12)}${note ? ` (${note})` : ""}`;
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
77
99
  /**
78
- * A superseded dependency excludes the artifact from acceptance. Repointing it automatically would
79
- * carry acceptance to content nobody reviewed against this artifact, so the owner is asked to
80
- * republish against the successor instead.
100
+ * A superseded or re-hashed dependency excludes the artifact from acceptance. Repointing it
101
+ * automatically would carry acceptance to content nobody reviewed against this artifact, so the
102
+ * owner is asked to republish against the successor or the new revision; reviewers of the old
103
+ * revision can then reattest when the change was behaviour-neutral.
81
104
  */
82
105
  async function assignDependencyRepins(launch: RunLaunch): Promise<void> {
83
106
  const { run } = launch;
@@ -89,12 +112,19 @@ async function assignDependencyRepins(launch: RunLaunch): Promise<void> {
89
112
  const successor = successorOf(ctx, target);
90
113
  return successor === undefined ? [] : [`${target} -> ${successor}`];
91
114
  });
92
- if (!moved.length) continue;
115
+ const drifted = artifact.dependencies.flatMap((dependency) => {
116
+ const drift = successorOf(ctx, dependency.target) === undefined ? revisionDrift(ctx, state, dependency) : undefined;
117
+ return drift === undefined ? [] : [drift];
118
+ });
119
+ if (!moved.length && !drifted.length) continue;
93
120
  if (state.messages.some((m) => m.sender === "parent" && m.kind === "handoff" && m.target === artifact.id && m.revision === artifact.hash)) continue;
121
+ const parts = [
122
+ moved.length ? `Dependencies of ${artifact.id} were superseded (${moved.join(", ")}). Check the corrected findings, update ${artifact.path} where the correction changes it, and republish with dependsOn naming the successors.` : "",
123
+ drifted.length ? `Dependencies of ${artifact.id} changed revision (${drifted.join(", ")}). Check whether the change affects ${artifact.path}, then republish with dependsOn pinning the current revisions.` : "",
124
+ "Acceptance needs a review of the new revision; reviewers of the earlier one can reattest when the behaviour they checked is unchanged.",
125
+ ].filter(Boolean);
94
126
  await runCollaborationAction(ctx, { action: "message", kind: "handoff", target: artifact.id, to: artifact.owner,
95
- text: `Dependencies of ${artifact.id} were superseded (${moved.join(", ")}). Check the corrected findings, update ${artifact.path} ` +
96
- "where the correction changes it, and republish with dependsOn naming the successors. Acceptance needs a fresh review of the new revision.",
97
- requestId: `repin:${artifact.id}:${artifact.hash}` });
127
+ text: parts.join(" "), requestId: `repin:${artifact.id}:${artifact.hash}` });
98
128
  }
99
129
  }
100
130
 
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,
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(),
@@ -112,6 +122,7 @@ export function createRun(
112
122
  model,
113
123
  plan,
114
124
  verifier,
125
+ untilStop: parsed.untilStop === true ? true : undefined,
115
126
  resets: [],
116
127
  dashboardUrl,
117
128
  };
@@ -130,7 +141,7 @@ function writeManifest(runDir: string, settings: Record<string, unknown>): void
130
141
  }
131
142
  };
132
143
  visit(root);
133
- 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)));
134
145
  fs.writeFileSync(path.join(runDir, "MANIFEST.json"), JSON.stringify({ protocol: 1, codeHash: code.digest("hex"),
135
146
  promptHash: createHash("sha256").update(String(settings.prompt)).digest("hex"),
136
147
  systemHash: createHash("sha256").update(fs.readFileSync(path.join(runDir, "SYSTEM.md"))).digest("hex"), settings, config }, null, 2));