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/README.md +42 -10
- package/docs/REFERENCE.md +141 -27
- package/package.json +1 -1
- package/src/args.ts +125 -50
- package/src/build-info.ts +65 -0
- package/src/collaboration-report.ts +23 -9
- package/src/dashboard-data.ts +480 -49
- package/src/dashboard-html.ts +390 -95
- package/src/dashboard.ts +205 -96
- package/src/ledger/collaboration.ts +36 -6
- package/src/ledger/facts.ts +52 -5
- package/src/ledger/files.ts +6 -1
- package/src/ledger/findings.ts +91 -1
- package/src/ledger/run-signals.ts +17 -2
- package/src/member.ts +62 -3
- package/src/prompt.ts +102 -21
- package/src/run/command.ts +68 -37
- package/src/run/completions.ts +32 -15
- package/src/run/controls.ts +74 -15
- package/src/run/finalize.ts +30 -21
- package/src/run/launch.ts +179 -34
- package/src/run/repair.ts +15 -7
- package/src/run/schedule.ts +43 -13
- package/src/run/setup.ts +36 -25
- package/src/run/solved.ts +125 -15
- package/src/run/supervise.ts +77 -10
- 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 +70 -5
- package/src/run/ui.ts +73 -28
- package/src/run-graph.ts +16 -9
- package/src/swarm-graph.ts +127 -0
- package/src/tool/actions.ts +206 -41
- package/src/tool/collaboration-actions.ts +75 -25
- package/src/tool/params.ts +10 -7
- package/src/tool/register.ts +13 -6
- 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
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
139
|
-
|
|
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
|
|
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,
|
|
275
|
+
await repairMember(launch, next.member, ms, { tokens, turns: budget.turns }, floors);
|
|
268
276
|
}
|
|
269
277
|
}
|
|
270
278
|
}
|
package/src/run/schedule.ts
CHANGED
|
@@ -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
|
-
/**
|
|
28
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
79
|
-
* carry acceptance to content nobody reviewed against this artifact, so the
|
|
80
|
-
* republish against the successor
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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(),
|
|
@@ -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));
|