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/README.md +22 -3
- package/docs/REFERENCE.md +86 -11
- package/package.json +1 -1
- package/src/args.ts +107 -47
- package/src/dashboard-data.ts +480 -49
- package/src/dashboard-html.ts +379 -92
- package/src/dashboard.ts +205 -96
- package/src/ledger/files.ts +6 -1
- package/src/member.ts +47 -0
- package/src/prompt.ts +29 -5
- package/src/run/command.ts +68 -37
- package/src/run/completions.ts +26 -14
- package/src/run/controls.ts +74 -15
- package/src/run/finalize.ts +24 -14
- package/src/run/launch.ts +179 -34
- package/src/run/setup.ts +35 -25
- package/src/run/supervise.ts +3 -1
- package/src/run/swarm-finalize.ts +121 -0
- package/src/run/swarm-report.ts +152 -0
- package/src/run/swarm.ts +156 -0
- package/src/run/synthesis.ts +141 -20
- package/src/run/types.ts +64 -5
- package/src/run/ui.ts +73 -28
- package/src/run-graph.ts +7 -7
- package/src/swarm-graph.ts +127 -0
- package/src/tool/actions.ts +61 -32
- package/src/tool/register.ts +3 -1
- package/src/widget.ts +80 -8
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
|
|
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: {
|
|
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
|
|
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
|
|
188
|
-
|
|
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 =
|
|
191
|
-
if (ctx.hasUI) mountWidget(
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(
|
|
204
|
-
if (state.active ===
|
|
205
|
-
ctx.ui.notify(`horde ${
|
|
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
|
|
209
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
78
|
+
spec: RunSpec,
|
|
79
79
|
dashboardUrl: string | undefined,
|
|
80
80
|
): { run: HordeRun; roles: string[] } {
|
|
81
|
-
const { count, prompt
|
|
81
|
+
const { count, prompt } = parsed;
|
|
82
|
+
const { id, hordeDir, runDir, plan } = spec;
|
|
82
83
|
const startedAt = Date.now();
|
|
83
|
-
|
|
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(
|
|
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));
|
package/src/run/supervise.ts
CHANGED
|
@@ -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
|
+
}
|