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/README.md +22 -3
- package/docs/REFERENCE.md +107 -17
- package/package.json +1 -1
- package/src/args.ts +107 -47
- package/src/citations.ts +16 -5
- 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/ledger/findings.ts +25 -6
- package/src/member.ts +47 -0
- package/src/prompt.ts +37 -6
- 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 +34 -25
- package/src/run/launch.ts +179 -34
- package/src/run/repair.ts +136 -24
- package/src/run/setup.ts +35 -25
- package/src/run/solved.ts +21 -8
- 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 +66 -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 +67 -38
- package/src/tool/collaboration-actions.ts +31 -6
- 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/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
|
-
|
|
28
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
149
|
-
const queue = queueFor(
|
|
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
|
-
|
|
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(
|
|
227
|
-
return launch.run.members.map((member) => ({ member, queue: queueFor(
|
|
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
|
|
347
|
+
const phase: RepairPhase = { launch, floors: repairFloors(), stalled: new Map() };
|
|
237
348
|
await scheduleCollaboration(launch, { settleMs: 0, liveOnly: false });
|
|
238
|
-
const budget = repairBudget(eligibleMembers(
|
|
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
|
|
359
|
+
const handed: HandedQueues = new Map();
|
|
249
360
|
for (;;) {
|
|
250
361
|
if (exhausted()) return;
|
|
251
|
-
const
|
|
252
|
-
|
|
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(
|
|
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(
|
|
382
|
+
await recordSkipped(phase, unserved, "insufficient-time", { remainingMs, floorMs: phase.floors.ms });
|
|
271
383
|
return;
|
|
272
384
|
}
|
|
273
385
|
await begin(budget);
|
|
274
|
-
|
|
275
|
-
await repairMember(
|
|
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
|
-
|
|
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));
|