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/prompt.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { PeerMessage, ReviewRecord } from "./ledger/collaboration.js";
|
|
|
9
9
|
import type { Idea } from "./ledger/ideas.js";
|
|
10
10
|
import type { ResourceEntry } from "./ledger/resources.js";
|
|
11
11
|
import type { SolvedSignal } from "./ledger/run-signals.js";
|
|
12
|
-
import type { MemberReset, MemberState } from "./member.js";
|
|
12
|
+
import type { MemberReset, MemberState, SiblingHorde } from "./member.js";
|
|
13
13
|
|
|
14
14
|
/** A resource with the non-owner members whose findings, evidence, reviews or leases cite it. */
|
|
15
15
|
export type ReportResource = ResourceEntry & { citedBy?: string[] | undefined };
|
|
@@ -34,6 +34,8 @@ export interface RunSummaryInput {
|
|
|
34
34
|
solvedCommitted?: boolean | undefined;
|
|
35
35
|
/** Members whose queued repair work was skipped for budget. */
|
|
36
36
|
repairSkipped?: string[] | undefined;
|
|
37
|
+
/** Members held back from later rounds because their attempt left every thread unchanged. */
|
|
38
|
+
repairStalled?: string[] | undefined;
|
|
37
39
|
/** The REPORT.md outcome, so SUMMARY.md states the same end cause. */
|
|
38
40
|
outcome?: string | undefined;
|
|
39
41
|
/** One-line run diagnosis shown in the report message. */
|
|
@@ -126,6 +128,8 @@ Collaboration loop:
|
|
|
126
128
|
a finding on that idea, explicitly set responseTarget=F#. Superseded F# requests follow the correction.
|
|
127
129
|
Requester: attach/review evidence on the current response target, then resolve target=Q# refs=[E#]
|
|
128
130
|
revision=current-hash text=decision, or defer with a reason. Disclosed finding limitations remain debt.
|
|
131
|
+
A blocking request about your own work resolves on the recipient's full supporting review of the
|
|
132
|
+
current revision, since you cannot review it yourself.
|
|
129
133
|
Do not repeat your existing check: inspect collaboration first and test a specific remaining gap.
|
|
130
134
|
A deferred blocking issue stays visible as acceptance debt; its recipient can still respond to it and
|
|
131
135
|
the requester then resolves it. Never silently ignore a peer's failed test.
|
|
@@ -169,7 +173,26 @@ Collaboration loop:
|
|
|
169
173
|
attempt after discovery exits. Repair attempts must address assigned feedback only.
|
|
170
174
|
`;
|
|
171
175
|
|
|
172
|
-
|
|
176
|
+
/** What a member of a swarm is told about the other hordes: shared read, local write. */
|
|
177
|
+
function siblingsSection(siblings: SiblingHorde[]): string {
|
|
178
|
+
if (siblings.length === 0) return "";
|
|
179
|
+
const lines = siblings.map(
|
|
180
|
+
(sibling) => `- ${sibling.label}: ${sibling.hordeDir}/${sibling.slice ? ` (workstream: ${sibling.slice})` : ""}`,
|
|
181
|
+
);
|
|
182
|
+
return `
|
|
183
|
+
Your horde is one of ${siblings.length + 1} hordes working the same task in parallel, each with its
|
|
184
|
+
own ledgers. The other hordes' directories are read-only for you:
|
|
185
|
+
|
|
186
|
+
${lines.join("\n")}
|
|
187
|
+
|
|
188
|
+
'horde facts' and 'horde resources' include their entries tagged [hN], and 'horde list' includes
|
|
189
|
+
their done and ruled-out ideas, so a fact, a rule-out, or a resource another horde recorded is not
|
|
190
|
+
re-derived here; reuse a sibling's resource by its path. Every write goes to your own horde's
|
|
191
|
+
ledgers; never write to a sibling's directory, and never claim or cite a sibling's ideas as yours.
|
|
192
|
+
`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function memberSystemPrompt(input: { hordeDir: string; count: number; siblings?: SiblingHorde[] | undefined }): string {
|
|
173
196
|
const count = input.count;
|
|
174
197
|
return `You are one member of a horde of ${count} agents. Every member received the same task
|
|
175
198
|
and works in parallel in this repository with its own separate context. Coordinate through the
|
|
@@ -188,9 +211,9 @@ horde tool. Durable run events retain messages, evidence, reviews and revisions;
|
|
|
188
211
|
- PLAN.md workstream partition and your assigned layer, when the run was started with plan.
|
|
189
212
|
|
|
190
213
|
Read them with the read tool. Write them ONLY with the horde tool; never edit them directly.
|
|
191
|
-
Put reproduction snippets under .
|
|
192
|
-
survives finalization and line references do not drift.
|
|
193
|
-
|
|
214
|
+
Put reproduction snippets under ${input.hordeDir}/runs/<run-id>/artifacts/ using PI_HORDE_RUN, so
|
|
215
|
+
evidence survives finalization and line references do not drift.
|
|
216
|
+
${siblingsSection(input.siblings ?? [])}
|
|
194
217
|
Protocol:
|
|
195
218
|
1. Run 'horde list', 'horde findings', 'horde facts', and read BOARD.md before planning.
|
|
196
219
|
2. Prefer the shared queue. Add an idea only for an uncovered question or concrete evidence gap.
|
|
@@ -254,7 +277,8 @@ Protocol:
|
|
|
254
277
|
proposal reopens for validation once every idea it named is done or ruled out. Pass the revision
|
|
255
278
|
from ANSWER.md or 'horde inspect' target=answer to either action, so stale reviews cannot approve
|
|
256
279
|
a newer proposal. An answer that cites a superseded F# or quotes a provisional T# is refused, and a
|
|
257
|
-
pending proposal is withdrawn for re-proposal when a finding it cites is superseded
|
|
280
|
+
pending proposal is withdrawn for re-proposal when a finding it cites is superseded or amended,
|
|
281
|
+
or an artifact it cites is republished. A request to
|
|
258
282
|
the proposer or a validator about the pending answer (target=answer) is served with the blockers.
|
|
259
283
|
The parent enforces coverage before wind-down.
|
|
260
284
|
|
|
@@ -275,7 +299,10 @@ export function taskMarkdown(input: {
|
|
|
275
299
|
startedAt: number;
|
|
276
300
|
verifier?: string | undefined;
|
|
277
301
|
untilStop?: boolean | undefined;
|
|
302
|
+
/** Set for a horde of a larger swarm: which horde this is and its workstream. */
|
|
303
|
+
swarm?: { id: string; horde: number; hordes: number; slice?: string | undefined } | undefined;
|
|
278
304
|
}): string {
|
|
305
|
+
const swarm = input.swarm;
|
|
279
306
|
return [
|
|
280
307
|
`# Horde run ${input.id}`,
|
|
281
308
|
"",
|
|
@@ -285,6 +312,8 @@ export function taskMarkdown(input: {
|
|
|
285
312
|
`Thinking: ${input.thinkingLevel ?? "parent default"}`,
|
|
286
313
|
`Verifier: ${input.verifier ?? "none"}`,
|
|
287
314
|
`Until stop: ${input.untilStop ? "yes (the run continues until stop-clean or stop-hard)" : "no"}`,
|
|
315
|
+
...(swarm ? [`Swarm: ${swarm.id} (horde h${swarm.horde} of ${swarm.hordes})`] : []),
|
|
316
|
+
...(swarm?.slice ? [`Workstream: ${swarm.slice}`] : []),
|
|
288
317
|
`Task-SHA256: ${taskHash(input.prompt)}`,
|
|
289
318
|
"",
|
|
290
319
|
"## Prompt",
|
|
@@ -438,10 +467,12 @@ export function runEndSentence(run: RunSummaryInput): string {
|
|
|
438
467
|
const attempts = run.members.reduce((n, m) => n + (m.repairs ?? 0), 0);
|
|
439
468
|
const killed = run.members.filter((m) => ["tokens", "turns", "time"].includes(m.repairStopReason ?? ""));
|
|
440
469
|
const skipped = run.repairSkipped ?? [];
|
|
470
|
+
const stalled = run.repairStalled ?? [];
|
|
441
471
|
if (attempts > 0 || skipped.length > 0) {
|
|
442
472
|
const detail = [
|
|
443
473
|
killed.length ? `${killed.length} killed on budget (${killed.map((m) => `${m.name}: ${m.repairStopReason}`).join(", ")})` : "",
|
|
444
474
|
skipped.length ? `${skipped.length} skipped for budget (${skipped.join(", ")})` : "",
|
|
475
|
+
stalled.length ? `${stalled.length} held after no progress (${stalled.join(", ")})` : "",
|
|
445
476
|
].filter(Boolean).join(", ");
|
|
446
477
|
parts.push(`Repair: ${attempts} attempt(s)${detail ? `, ${detail}` : ""}.`);
|
|
447
478
|
}
|
package/src/run/command.ts
CHANGED
|
@@ -1,20 +1,40 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { USAGE, parseHordeArgs } from "../args.js";
|
|
3
|
+
import type { HordeTarget } from "../args.js";
|
|
3
4
|
import { clearCleanStop } from "../ledger/run-signals.js";
|
|
4
5
|
import { terminateAllChildren } from "../member.js";
|
|
5
6
|
import { formatStatusReport } from "../prompt.js";
|
|
6
|
-
import { requestCleanStop, setPaused, stopHard, targetMembers } from "./controls.js";
|
|
7
|
+
import { requestCleanStop, resolveTargets, setPaused, stopHard, targetMembers } from "./controls.js";
|
|
8
|
+
import type { RunSelection } from "./controls.js";
|
|
7
9
|
import { stopRunTimers } from "./finalize.js";
|
|
8
|
-
import { closeDashboard,
|
|
9
|
-
import type {
|
|
10
|
+
import { closeDashboard, startSwarm } from "./launch.js";
|
|
11
|
+
import type { HordeState, Swarm } from "./types.js";
|
|
10
12
|
import { unmountWidget } from "./ui.js";
|
|
11
13
|
|
|
12
|
-
/** The active
|
|
13
|
-
function requireActive(state: HordeState, ctx: ExtensionCommandContext):
|
|
14
|
+
/** The active swarm, or undefined after telling the user there is none. */
|
|
15
|
+
function requireActive(state: HordeState, ctx: ExtensionCommandContext): Swarm | undefined {
|
|
14
16
|
if (!state.active) ctx.ui.notify("no horde run is active", "info");
|
|
15
17
|
return state.active;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/** The runs and members a control command addresses, or undefined after the user was told why not. */
|
|
21
|
+
function select(state: HordeState, target: HordeTarget, ctx: ExtensionCommandContext): RunSelection[] | undefined {
|
|
22
|
+
const swarm = requireActive(state, ctx);
|
|
23
|
+
if (!swarm) return undefined;
|
|
24
|
+
const selections = resolveTargets(swarm, target, ctx);
|
|
25
|
+
if (selections && selections.length === 0) ctx.ui.notify("the target selects no members", "info");
|
|
26
|
+
return selections && selections.length > 0 ? selections : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function showStatus(selections: RunSelection[], ctx: ExtensionCommandContext): void {
|
|
30
|
+
const blocks: string[] = [];
|
|
31
|
+
for (const { run, target } of selections) {
|
|
32
|
+
const members = targetMembers(run, target, ctx);
|
|
33
|
+
if (members) blocks.push(formatStatusReport({ ...run, members }));
|
|
34
|
+
}
|
|
35
|
+
if (blocks.length > 0) ctx.ui.notify(blocks.join("\n\n"), "info");
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
/** Handle one `/agent-horde` invocation. */
|
|
19
39
|
export async function handleHordeCommand(
|
|
20
40
|
state: HordeState,
|
|
@@ -31,61 +51,72 @@ export async function handleHordeCommand(
|
|
|
31
51
|
ctx.ui.notify(USAGE, "info");
|
|
32
52
|
return;
|
|
33
53
|
case "status": {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
if (run && members) ctx.ui.notify(formatStatusReport({ ...run, members }), "info");
|
|
54
|
+
const selections = select(state, parsed.target, ctx);
|
|
55
|
+
if (selections) showStatus(selections, ctx);
|
|
37
56
|
return;
|
|
38
57
|
}
|
|
39
58
|
case "stop": {
|
|
40
|
-
const
|
|
41
|
-
if (!
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
graceMs: parsed.graceMs,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
stopHard(run, parsed.target, ctx);
|
|
59
|
+
const selections = select(state, parsed.target, ctx);
|
|
60
|
+
if (!selections) return;
|
|
61
|
+
for (const { run, target } of selections) {
|
|
62
|
+
if (parsed.mode === "clean") {
|
|
63
|
+
await requestCleanStop(run, target, ctx, { graceMs: parsed.graceMs, postmortem: parsed.postmortem });
|
|
64
|
+
} else {
|
|
65
|
+
stopHard(run, target, ctx);
|
|
66
|
+
}
|
|
49
67
|
}
|
|
50
68
|
return;
|
|
51
69
|
}
|
|
52
70
|
case "pause":
|
|
53
71
|
case "resume": {
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
72
|
+
const selections = select(state, parsed.target, ctx);
|
|
73
|
+
if (!selections) return;
|
|
74
|
+
for (const { run, target } of selections) setPaused(run, target, ctx, parsed.kind === "pause");
|
|
56
75
|
return;
|
|
57
76
|
}
|
|
58
77
|
case "run":
|
|
59
78
|
if (state.active || state.starting) {
|
|
79
|
+
const noun = state.active && state.active.hordes > 1 ? "swarm" : "horde";
|
|
60
80
|
ctx.ui.notify(
|
|
61
|
-
|
|
81
|
+
`${noun} ${state.active?.id ?? ""} is still active; use /agent-horde status or /agent-horde stop-hard`.replace(" ", " "),
|
|
62
82
|
"error",
|
|
63
83
|
);
|
|
64
84
|
return;
|
|
65
85
|
}
|
|
66
|
-
await
|
|
86
|
+
await startSwarm(state, ctx, parsed);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function abortSwarm(swarm: Swarm): void {
|
|
91
|
+
swarm.deliverableAbort?.abort();
|
|
92
|
+
for (const run of swarm.runs) {
|
|
93
|
+
run.stopped = true;
|
|
94
|
+
run.userStop = true;
|
|
95
|
+
run.postmortemAbort?.abort();
|
|
96
|
+
run.deliverableAbort?.abort();
|
|
97
|
+
stopRunTimers(run);
|
|
98
|
+
clearCleanStop(run.runDir);
|
|
99
|
+
run.controller.abort();
|
|
100
|
+
run.ui = undefined;
|
|
67
101
|
}
|
|
102
|
+
try {
|
|
103
|
+
unmountWidget(swarm);
|
|
104
|
+
} catch {
|
|
105
|
+
// The UI is already being torn down.
|
|
106
|
+
}
|
|
107
|
+
swarm.ui = undefined;
|
|
68
108
|
}
|
|
69
109
|
|
|
70
|
-
/** Session teardown: abort the active
|
|
110
|
+
/** Session teardown: abort the active swarm and every helper, without finalizing. */
|
|
71
111
|
export function shutdownHorde(state: HordeState): void {
|
|
72
112
|
state.disposed = true;
|
|
73
113
|
process.off("exit", terminateAllChildren);
|
|
74
114
|
closeDashboard(state);
|
|
75
|
-
|
|
115
|
+
state.plannerAbort?.abort();
|
|
116
|
+
const active = state.active;
|
|
117
|
+
const finalizing = state.finalizing;
|
|
76
118
|
state.active = undefined;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
run.postmortemAbort?.abort();
|
|
81
|
-
run.deliverableAbort?.abort();
|
|
82
|
-
stopRunTimers(run);
|
|
83
|
-
clearCleanStop(run.runDir);
|
|
84
|
-
run.controller.abort();
|
|
85
|
-
try {
|
|
86
|
-
unmountWidget(run);
|
|
87
|
-
} catch {
|
|
88
|
-
// The UI is already being torn down.
|
|
89
|
-
}
|
|
90
|
-
run.ui = undefined;
|
|
119
|
+
state.finalizing = undefined;
|
|
120
|
+
if (active) abortSwarm(active);
|
|
121
|
+
if (finalizing && finalizing !== active) abortSwarm(finalizing);
|
|
91
122
|
}
|
package/src/run/completions.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
-
import
|
|
2
|
+
import { hordeLabel } from "./types.js";
|
|
3
|
+
import type { Swarm } from "./types.js";
|
|
3
4
|
|
|
4
5
|
const CONTROL_SUBCOMMANDS: Array<{
|
|
5
6
|
value: string;
|
|
@@ -41,7 +42,24 @@ const EXTRA_COMPLETIONS: Record<string, Array<{ value: string; description: stri
|
|
|
41
42
|
plan: [DASHBOARD_COMPLETION],
|
|
42
43
|
};
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
/** `all`, then the members of a single horde, or `hN` and `hN:member-M` for a larger swarm. */
|
|
46
|
+
function targetNames(active: Swarm | undefined): Array<{ name: string; description: string }> {
|
|
47
|
+
if (!active) return [{ name: "all", description: "all members" }];
|
|
48
|
+
const total = active.runs.reduce((sum, run) => sum + run.members.length, 0);
|
|
49
|
+
const names = [{ name: "all", description: active.hordes > 1 ? `all ${total} members of ${active.hordes} hordes` : `all ${total} members` }];
|
|
50
|
+
if (active.hordes === 1) {
|
|
51
|
+
for (const member of active.runs[0]?.members ?? []) names.push({ name: member.name, description: member.status });
|
|
52
|
+
return names;
|
|
53
|
+
}
|
|
54
|
+
for (const run of active.runs) {
|
|
55
|
+
const label = hordeLabel(run.horde);
|
|
56
|
+
names.push({ name: label, description: `horde ${label}: ${run.members.length} members` });
|
|
57
|
+
for (const member of run.members) names.push({ name: `${label}:${member.name}`, description: member.status });
|
|
58
|
+
}
|
|
59
|
+
return names;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function controlCompletions(prefix: string, active: Swarm | undefined): AutocompleteItem[] | null {
|
|
45
63
|
const space = prefix.search(/\s/);
|
|
46
64
|
if (space === -1 && prefix.startsWith("-")) {
|
|
47
65
|
const items = LAUNCH_FLAG_COMPLETIONS.filter((flag) => flag.value.startsWith(prefix)).map(
|
|
@@ -66,18 +84,12 @@ export function controlCompletions(prefix: string, active: HordeRun | undefined)
|
|
|
66
84
|
const match = /^([\s\S]*?)([^\s,]*)$/.exec(rest);
|
|
67
85
|
const base = match?.[1] ?? "";
|
|
68
86
|
const token = match?.[2] ?? "";
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
description:
|
|
76
|
-
name === "all"
|
|
77
|
-
? active
|
|
78
|
-
? `all ${active.count} members`
|
|
79
|
-
: "all members"
|
|
80
|
-
: active?.members.find((m) => m.name === name)?.status,
|
|
87
|
+
const targetItems: AutocompleteItem[] = (sub.takesTarget ? targetNames(active) : [])
|
|
88
|
+
.filter((target) => target.name.startsWith(token))
|
|
89
|
+
.map((target) => ({
|
|
90
|
+
value: `${head} ${base}${target.name}`,
|
|
91
|
+
label: target.name,
|
|
92
|
+
description: target.description,
|
|
81
93
|
}));
|
|
82
94
|
const extraItems: AutocompleteItem[] = (EXTRA_COMPLETIONS[head] ?? [])
|
|
83
95
|
.filter((extra) => {
|
package/src/run/controls.ts
CHANGED
|
@@ -5,7 +5,8 @@ import { postBoard } from "../ledger/board.js";
|
|
|
5
5
|
import { readCleanStop, readSolvedState, writeCleanStop } from "../ledger/run-signals.js";
|
|
6
6
|
import { recordLifecycle } from "./lifecycle.js";
|
|
7
7
|
import type { MemberState } from "../member.js";
|
|
8
|
-
import
|
|
8
|
+
import { hordeLabel } from "./types.js";
|
|
9
|
+
import type { HordeRun, Swarm } from "./types.js";
|
|
9
10
|
import { refreshRun, safeNotify } from "./ui.js";
|
|
10
11
|
|
|
11
12
|
/** How long stop-clean lets members wrap up before it escalates to stop-hard. */
|
|
@@ -26,10 +27,63 @@ interface CleanStopOptions {
|
|
|
26
27
|
label?: CleanStopLabel | undefined;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
/** A control target resolved to one run: every member, or the named ones. */
|
|
31
|
+
export type RunTarget = { kind: "all" } | { kind: "members"; names: string[] };
|
|
32
|
+
|
|
33
|
+
/** One run a control command applies to, with its share of the target. */
|
|
34
|
+
export interface RunSelection {
|
|
35
|
+
run: HordeRun;
|
|
36
|
+
target: RunTarget;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a target across the swarm: `all` and `hN` select whole runs, a bare member selects
|
|
41
|
+
* that member in every horde, `hN:member-M` one member of one horde. Runs the target does not
|
|
42
|
+
* touch are left out. Notifies and returns undefined when a horde or member does not exist.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveTargets(
|
|
45
|
+
swarm: Swarm,
|
|
31
46
|
target: HordeTarget,
|
|
32
|
-
|
|
47
|
+
ctx: ExtensionCommandContext,
|
|
48
|
+
): RunSelection[] | undefined {
|
|
49
|
+
if (target.kind === "all") return swarm.runs.map((run) => ({ run, target: { kind: "all" } }));
|
|
50
|
+
const whole = new Set<HordeRun>();
|
|
51
|
+
const named = new Map<HordeRun, Set<string>>();
|
|
52
|
+
const invalid: string[] = [];
|
|
53
|
+
for (const item of target.items) {
|
|
54
|
+
const runs = item.horde === undefined ? swarm.runs : swarm.runs.filter((run) => run.horde === item.horde);
|
|
55
|
+
if (runs.length === 0) {
|
|
56
|
+
invalid.push(hordeLabel(item.horde ?? 0));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
for (const run of runs) {
|
|
60
|
+
if (item.member === undefined) {
|
|
61
|
+
whole.add(run);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!run.members.some((member) => member.name === item.member)) {
|
|
65
|
+
invalid.push(swarm.hordes > 1 ? `${hordeLabel(run.horde)}:${item.member}` : item.member);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!named.has(run)) named.set(run, new Set());
|
|
69
|
+
named.get(run)!.add(item.member);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (invalid.length > 0) {
|
|
73
|
+
const hordes = invalid.filter((name) => !name.includes("member"));
|
|
74
|
+
const members = invalid.filter((name) => name.includes("member"));
|
|
75
|
+
if (hordes.length > 0) ctx.ui.notify(`no such horde: ${[...new Set(hordes)].join(", ")}`, "error");
|
|
76
|
+
if (members.length > 0) ctx.ui.notify(`no such member: ${[...new Set(members)].join(", ")}`, "error");
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return swarm.runs.flatMap((run): RunSelection[] => {
|
|
80
|
+
if (whole.has(run)) return [{ run, target: { kind: "all" } }];
|
|
81
|
+
const names = named.get(run);
|
|
82
|
+
return names ? [{ run, target: { kind: "members", names: [...names] } }] : [];
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function selectedMembers(run: HordeRun, target: RunTarget): { members: MemberState[]; invalid: string[] } {
|
|
33
87
|
if (target.kind === "all") return { members: run.members, invalid: [] };
|
|
34
88
|
const known = new Set(run.members.map((m) => m.name));
|
|
35
89
|
return {
|
|
@@ -38,10 +92,10 @@ function selectedMembers(
|
|
|
38
92
|
};
|
|
39
93
|
}
|
|
40
94
|
|
|
41
|
-
/** Resolve a target, or notify and return undefined when it names unknown members. */
|
|
95
|
+
/** Resolve a run target, or notify and return undefined when it names unknown members. */
|
|
42
96
|
export function targetMembers(
|
|
43
97
|
run: HordeRun,
|
|
44
|
-
target:
|
|
98
|
+
target: RunTarget,
|
|
45
99
|
ctx: ExtensionCommandContext,
|
|
46
100
|
): MemberState[] | undefined {
|
|
47
101
|
const { members, invalid } = selectedMembers(run, target);
|
|
@@ -63,7 +117,12 @@ export function hardStopMembers(run: HordeRun, names: string[]): void {
|
|
|
63
117
|
}
|
|
64
118
|
}
|
|
65
119
|
|
|
66
|
-
|
|
120
|
+
/** `h2: ` in a swarm's notifications, nothing for a swarm of one. */
|
|
121
|
+
function prefix(run: HordeRun): string {
|
|
122
|
+
return (run.hordes ?? 1) > 1 ? `${hordeLabel(run.horde)}: ` : "";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function stopHard(run: HordeRun, target: RunTarget, ctx: ExtensionCommandContext): void {
|
|
67
126
|
const members = targetMembers(run, target, ctx);
|
|
68
127
|
if (!members) return;
|
|
69
128
|
const names = members.map((m) => m.name);
|
|
@@ -72,13 +131,13 @@ export function stopHard(run: HordeRun, target: HordeTarget, ctx: ExtensionComma
|
|
|
72
131
|
run.stopLabel = "stopped by user";
|
|
73
132
|
hardStopMembers(run, names);
|
|
74
133
|
void recordLifecycle(run, "stop-hard", { members: names, by: "user" });
|
|
75
|
-
ctx.ui.notify(
|
|
134
|
+
ctx.ui.notify(`${prefix(run)}stop-hard: terminating ${names.join(", ")}`, "warning");
|
|
76
135
|
}
|
|
77
136
|
|
|
78
137
|
/** Suspend (SIGSTOP) or resume (SIGCONT) the targeted live members. */
|
|
79
138
|
export function setPaused(
|
|
80
139
|
run: HordeRun,
|
|
81
|
-
target:
|
|
140
|
+
target: RunTarget,
|
|
82
141
|
ctx: ExtensionCommandContext,
|
|
83
142
|
paused: boolean,
|
|
84
143
|
): void {
|
|
@@ -104,7 +163,7 @@ export function setPaused(
|
|
|
104
163
|
: changed > 0
|
|
105
164
|
? `resumed ${changed} member(s)`
|
|
106
165
|
: "no paused members to resume";
|
|
107
|
-
ctx.ui.notify(message
|
|
166
|
+
ctx.ui.notify(`${prefix(run)}${message}`, "info");
|
|
108
167
|
}
|
|
109
168
|
|
|
110
169
|
function stopLabelFor(label: CleanStopLabel): string {
|
|
@@ -134,7 +193,7 @@ function holdCleanStop(
|
|
|
134
193
|
run.cleanStopUntil = Date.now() + holdMs;
|
|
135
194
|
run.cleanStopTimer = setTimeout(() => finishCleanStop(run, members, label), holdMs);
|
|
136
195
|
run.cleanStopTimer.unref?.();
|
|
137
|
-
safeNotify(run, message
|
|
196
|
+
safeNotify(run, `${prefix(run)}${message}`, "info");
|
|
138
197
|
refreshRun(run);
|
|
139
198
|
}
|
|
140
199
|
|
|
@@ -184,7 +243,7 @@ function finishCleanStop(run: HordeRun, members: MemberState[], label: CleanStop
|
|
|
184
243
|
run.cleanStopUntil = undefined;
|
|
185
244
|
hardStopMembers(run, run.cleanStopNames);
|
|
186
245
|
void recordLifecycle(run, "stop-hard", { members: run.cleanStopNames, by: label });
|
|
187
|
-
safeNotify(run, `${label} deadline reached: terminating ${run.cleanStopNames.join(", ")}`, "warning");
|
|
246
|
+
safeNotify(run, `${prefix(run)}${label} deadline reached: terminating ${run.cleanStopNames.join(", ")}`, "warning");
|
|
188
247
|
refreshRun(run);
|
|
189
248
|
}
|
|
190
249
|
|
|
@@ -202,7 +261,7 @@ function wakePaused(run: HordeRun, members: MemberState[]): void {
|
|
|
202
261
|
/** Ask the targeted members to wrap up, with a hard stop once the grace period ends. */
|
|
203
262
|
export async function requestCleanStop(
|
|
204
263
|
run: HordeRun,
|
|
205
|
-
target:
|
|
264
|
+
target: RunTarget,
|
|
206
265
|
ctx: ExtensionCommandContext,
|
|
207
266
|
options: CleanStopOptions = {},
|
|
208
267
|
): Promise<void> {
|
|
@@ -210,7 +269,7 @@ export async function requestCleanStop(
|
|
|
210
269
|
const members = targetMembers(run, target, ctx);
|
|
211
270
|
if (!members) return;
|
|
212
271
|
if (members.length === 0) {
|
|
213
|
-
ctx.ui.notify(
|
|
272
|
+
ctx.ui.notify(`${prefix(run)}no running members to stop`, "info");
|
|
214
273
|
return;
|
|
215
274
|
}
|
|
216
275
|
const grace = options.graceMs ?? CLEAN_STOP_GRACE_MS;
|
|
@@ -236,7 +295,7 @@ export async function requestCleanStop(
|
|
|
236
295
|
refreshRun(run);
|
|
237
296
|
const graceSeconds = Math.round(grace / 1000);
|
|
238
297
|
ctx.ui.notify(
|
|
239
|
-
`${label}: ${requested.length} member(s) wrapping up, hard stop in ${graceSeconds}s`,
|
|
298
|
+
`${prefix(run)}${label}: ${requested.length} member(s) wrapping up, hard stop in ${graceSeconds}s`,
|
|
240
299
|
"warning",
|
|
241
300
|
);
|
|
242
301
|
try {
|
package/src/run/finalize.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { clusterFindings } from "../ledger/clusters.js";
|
|
|
20
20
|
import { listFacts } from "../ledger/facts.js";
|
|
21
21
|
import type { FactEntry } from "../ledger/facts.js";
|
|
22
22
|
import { readHordeFile } from "../ledger/files.js";
|
|
23
|
-
import { listFindingsForRun, parseFindings,
|
|
23
|
+
import { listFindingsForRun, parseFindings, staleAnswerFor } from "../ledger/findings.js";
|
|
24
24
|
import type { FindingEntry } from "../ledger/findings.js";
|
|
25
25
|
import { countIdeas, parseIdeas, reconcileOwnedIdeas } from "../ledger/ideas.js";
|
|
26
26
|
import type { Idea } from "../ledger/ideas.js";
|
|
@@ -35,9 +35,10 @@ import { runGraphMarkdown } from "../run-graph-mermaid.js";
|
|
|
35
35
|
import type { CleanupStatus, ReportResource, RunReportInput } from "../prompt.js";
|
|
36
36
|
import { buildDiagnosis } from "./diagnosis.js";
|
|
37
37
|
import { recordLifecycle, recordPhase } from "./lifecycle.js";
|
|
38
|
+
import { noteRunFinalized, noteRunReported } from "./swarm-finalize.js";
|
|
38
39
|
import { runDeliverable, runPostmortem } from "./synthesis.js";
|
|
39
|
-
import type { HordeRun, HordeState } from "./types.js";
|
|
40
|
-
import { REPORT_TYPE, refreshRun, reportDetails, safeNotify
|
|
40
|
+
import type { HordeRun, HordeState, Swarm } from "./types.js";
|
|
41
|
+
import { REPORT_TYPE, refreshRun, reportDetails, safeNotify } from "./ui.js";
|
|
41
42
|
|
|
42
43
|
/** Most deliverable warnings shown inline; REPORT.md keeps the full list. */
|
|
43
44
|
const INLINE_WARNING_CAP = 12;
|
|
@@ -136,19 +137,18 @@ interface SettledAnswer {
|
|
|
136
137
|
status: string;
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
/** Findings the proposal names that a later finding corrected; labeled, not re-versioned. */
|
|
140
140
|
/**
|
|
141
141
|
* The proposal body is the member's verbatim text and can predate later challenges and corrections.
|
|
142
|
-
* The status names the blockers still open at freeze and the
|
|
143
|
-
*
|
|
142
|
+
* The status names the blockers still open at freeze and the citations that changed after the
|
|
143
|
+
* proposal pinned them (superseded or amended findings, republished artifacts), and, once
|
|
144
|
+
* synthesis wrote it, points the operator at DELIVERABLE.md as the reconciled output.
|
|
144
145
|
*/
|
|
145
|
-
function annotateAnswer(run: HordeRun, answer: SettledAnswer, acceptance: { hash: string; issues: string[] },
|
|
146
|
+
function annotateAnswer(run: HordeRun, answer: SettledAnswer, acceptance: { hash: string; issues: string[] }, stale: string[]): void {
|
|
146
147
|
const blockers = acceptance.issues.filter((issue) => /^Q\d+ .*: \w+ blocking /.test(issue)).map((issue) => issue.replace(/:/, ""));
|
|
147
148
|
const parts = [answer.status];
|
|
148
149
|
if (acceptance.issues.length) parts.push("acceptance incomplete");
|
|
149
150
|
if (blockers.length) parts.push(`open blocking at freeze: ${blockers.join(", ")}`);
|
|
150
|
-
|
|
151
|
-
if (superseded.length) parts.push(`cites superseded ${superseded.join(", ")}`);
|
|
151
|
+
if (stale.length) parts.push(`cites ${stale.join(", ")}`);
|
|
152
152
|
parts.push(`see ACCEPTANCE.md (snapshot ${acceptance.hash})`);
|
|
153
153
|
if (run.deliverable) parts.push("reconciled operator output: DELIVERABLE.md; the proposal text below is verbatim and may be superseded there");
|
|
154
154
|
try {
|
|
@@ -279,12 +279,11 @@ async function runCleanup(run: HordeRun, item: PendingCleanup): Promise<CleanupR
|
|
|
279
279
|
* Tear down the labs this run registered before writing the report, so the
|
|
280
280
|
* report states whether cleanup succeeded and no containers outlive the run.
|
|
281
281
|
*/
|
|
282
|
-
async function runCleanups(
|
|
282
|
+
async function runCleanups(run: HordeRun): Promise<CleanupResult[]> {
|
|
283
283
|
const pending =
|
|
284
284
|
process.env.PI_HORDE_CLEANUP === "off" ? [] : pendingCleanups(run.hordeDir, run.id);
|
|
285
285
|
if (pending.length === 0) return [];
|
|
286
286
|
run.finalizing = ["cleanup"];
|
|
287
|
-
state.finalizing = run;
|
|
288
287
|
refreshRun(run);
|
|
289
288
|
const cleanups: CleanupResult[] = [];
|
|
290
289
|
for (const item of pending) cleanups.push(await runCleanup(run, item));
|
|
@@ -337,6 +336,7 @@ function writeRunReports(
|
|
|
337
336
|
});
|
|
338
337
|
const memberPosts = lastBoardPostsByMember(run.hordeDir, run.id);
|
|
339
338
|
const outcome = runOutcome(run);
|
|
339
|
+
run.outcome = outcome;
|
|
340
340
|
let events: HordeEvent[] = [];
|
|
341
341
|
try {
|
|
342
342
|
events = readEvents(run.hordeDir, run.id);
|
|
@@ -461,7 +461,6 @@ async function synthesize(
|
|
|
461
461
|
if (wantPostmortem) steps.push("post-mortem");
|
|
462
462
|
if (steps.length > 0) {
|
|
463
463
|
run.finalizing = steps;
|
|
464
|
-
state.finalizing = run;
|
|
465
464
|
refreshRun(run);
|
|
466
465
|
}
|
|
467
466
|
run.deliverable = undefined;
|
|
@@ -502,7 +501,15 @@ async function synthesize(
|
|
|
502
501
|
}
|
|
503
502
|
}
|
|
504
503
|
|
|
505
|
-
|
|
504
|
+
/**
|
|
505
|
+
* Inject the run report into the parent conversation. A horde of a larger swarm only notifies:
|
|
506
|
+
* the swarm sends one merged report once every horde has finished.
|
|
507
|
+
*/
|
|
508
|
+
function sendReport(state: HordeState, swarm: Swarm, run: HordeRun, ledgers: RunLedgers, now: number): void {
|
|
509
|
+
if (swarm.hordes > 1) {
|
|
510
|
+
safeNotify(run, `horde ${run.id} ${run.stopped ? "stopped" : "finished"} (${run.outcome ?? "finished"})`, "info");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
506
513
|
const sections = [reportText(run, ledgers.ideaCounts, ledgers.findings.length, now)];
|
|
507
514
|
if (run.deliverable) sections.push("## Deliverable", "", run.deliverable);
|
|
508
515
|
if (run.postmortem) sections.push("## Post-mortem", "", run.postmortem);
|
|
@@ -535,8 +542,11 @@ export function writeRunGraph(run: HordeRun): void {
|
|
|
535
542
|
}
|
|
536
543
|
}
|
|
537
544
|
|
|
538
|
-
/**
|
|
539
|
-
|
|
545
|
+
/**
|
|
546
|
+
* Wind a run down once every member has exited: reconcile, report, synthesize, notify. The
|
|
547
|
+
* swarm's slot frees once every horde has reported, and the swarm step follows the last finalize.
|
|
548
|
+
*/
|
|
549
|
+
export async function finalizeRun(state: HordeState, swarm: Swarm, run: HordeRun): Promise<void> {
|
|
540
550
|
const now = Date.now();
|
|
541
551
|
try {
|
|
542
552
|
if (state.disposed) return;
|
|
@@ -548,25 +558,24 @@ export async function finalizeRun(state: HordeState, run: HordeRun): Promise<voi
|
|
|
548
558
|
run.acceptance = acceptance.status;
|
|
549
559
|
run.snapshotHash = acceptance.hash;
|
|
550
560
|
await recordLifecycle(run, "acceptance", { status: acceptance.status, hash: acceptance.hash, issues: acceptance.issues.length });
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
if (annotate) annotateAnswer(run, answer, acceptance,
|
|
561
|
+
const stale = answer ? staleAnswerFor(run.hordeDir, run.id, answer.proposal.text, answer.proposal.cites) : [];
|
|
562
|
+
const annotate = answer !== undefined && (acceptance.issues.length > 0 || stale.length > 0);
|
|
563
|
+
if (annotate) annotateAnswer(run, answer, acceptance, stale);
|
|
554
564
|
// Workers and bounded repairs have exited; only now may their labs be torn down.
|
|
555
|
-
const cleanups = await runCleanups(
|
|
565
|
+
const cleanups = await runCleanups(run);
|
|
556
566
|
const reportInput = writeRunReports(run, ledgers, releasedIdeas, cleanups, now);
|
|
557
567
|
// Free the slot before the (possibly slow) synthesis so a new run can start.
|
|
558
|
-
|
|
568
|
+
noteRunReported(state, swarm, run);
|
|
559
569
|
await synthesize(state, run, ledgers, reportInput, now);
|
|
560
|
-
if (annotate && run.deliverable) annotateAnswer(run, answer, acceptance,
|
|
561
|
-
sendReport(state, run, ledgers, now);
|
|
570
|
+
if (annotate && run.deliverable) annotateAnswer(run, answer, acceptance, stale);
|
|
571
|
+
sendReport(state, swarm, run, ledgers, now);
|
|
562
572
|
await recordLifecycle(run, "run-end", { outcome: runOutcome(run), elapsedMs: Date.now() - run.startedAt, stopLabel: run.stopLabel });
|
|
563
573
|
writeRunGraph(run);
|
|
564
574
|
} catch (error) {
|
|
565
575
|
safeNotify(run, `horde ${run.id} failed to finalize: ${errorMessage(error)}`, "error");
|
|
566
576
|
} finally {
|
|
567
|
-
|
|
577
|
+
noteRunReported(state, swarm, run);
|
|
568
578
|
run.finalizing = undefined;
|
|
569
|
-
|
|
570
|
-
unmountWidget(run);
|
|
579
|
+
await noteRunFinalized(state, swarm, run);
|
|
571
580
|
}
|
|
572
581
|
}
|