agent-horde 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -3
- package/docs/REFERENCE.md +86 -11
- package/package.json +1 -1
- package/src/args.ts +107 -47
- package/src/dashboard-data.ts +480 -49
- package/src/dashboard-html.ts +379 -92
- package/src/dashboard.ts +205 -96
- package/src/ledger/files.ts +6 -1
- package/src/member.ts +47 -0
- package/src/prompt.ts +29 -5
- package/src/run/command.ts +68 -37
- package/src/run/completions.ts +26 -14
- package/src/run/controls.ts +74 -15
- package/src/run/finalize.ts +24 -14
- package/src/run/launch.ts +179 -34
- package/src/run/setup.ts +35 -25
- package/src/run/supervise.ts +3 -1
- package/src/run/swarm-finalize.ts +121 -0
- package/src/run/swarm-report.ts +152 -0
- package/src/run/swarm.ts +156 -0
- package/src/run/synthesis.ts +141 -20
- package/src/run/types.ts +64 -5
- package/src/run/ui.ts +73 -28
- package/src/run-graph.ts +7 -7
- package/src/swarm-graph.ts +127 -0
- package/src/tool/actions.ts +61 -32
- package/src/tool/register.ts +3 -1
- package/src/widget.ts +80 -8
package/src/run/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
|
@@ -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;
|
|
@@ -279,12 +280,11 @@ async function runCleanup(run: HordeRun, item: PendingCleanup): Promise<CleanupR
|
|
|
279
280
|
* Tear down the labs this run registered before writing the report, so the
|
|
280
281
|
* report states whether cleanup succeeded and no containers outlive the run.
|
|
281
282
|
*/
|
|
282
|
-
async function runCleanups(
|
|
283
|
+
async function runCleanups(run: HordeRun): Promise<CleanupResult[]> {
|
|
283
284
|
const pending =
|
|
284
285
|
process.env.PI_HORDE_CLEANUP === "off" ? [] : pendingCleanups(run.hordeDir, run.id);
|
|
285
286
|
if (pending.length === 0) return [];
|
|
286
287
|
run.finalizing = ["cleanup"];
|
|
287
|
-
state.finalizing = run;
|
|
288
288
|
refreshRun(run);
|
|
289
289
|
const cleanups: CleanupResult[] = [];
|
|
290
290
|
for (const item of pending) cleanups.push(await runCleanup(run, item));
|
|
@@ -337,6 +337,7 @@ function writeRunReports(
|
|
|
337
337
|
});
|
|
338
338
|
const memberPosts = lastBoardPostsByMember(run.hordeDir, run.id);
|
|
339
339
|
const outcome = runOutcome(run);
|
|
340
|
+
run.outcome = outcome;
|
|
340
341
|
let events: HordeEvent[] = [];
|
|
341
342
|
try {
|
|
342
343
|
events = readEvents(run.hordeDir, run.id);
|
|
@@ -461,7 +462,6 @@ async function synthesize(
|
|
|
461
462
|
if (wantPostmortem) steps.push("post-mortem");
|
|
462
463
|
if (steps.length > 0) {
|
|
463
464
|
run.finalizing = steps;
|
|
464
|
-
state.finalizing = run;
|
|
465
465
|
refreshRun(run);
|
|
466
466
|
}
|
|
467
467
|
run.deliverable = undefined;
|
|
@@ -502,7 +502,15 @@ async function synthesize(
|
|
|
502
502
|
}
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
-
|
|
505
|
+
/**
|
|
506
|
+
* Inject the run report into the parent conversation. A horde of a larger swarm only notifies:
|
|
507
|
+
* the swarm sends one merged report once every horde has finished.
|
|
508
|
+
*/
|
|
509
|
+
function sendReport(state: HordeState, swarm: Swarm, run: HordeRun, ledgers: RunLedgers, now: number): void {
|
|
510
|
+
if (swarm.hordes > 1) {
|
|
511
|
+
safeNotify(run, `horde ${run.id} ${run.stopped ? "stopped" : "finished"} (${run.outcome ?? "finished"})`, "info");
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
506
514
|
const sections = [reportText(run, ledgers.ideaCounts, ledgers.findings.length, now)];
|
|
507
515
|
if (run.deliverable) sections.push("## Deliverable", "", run.deliverable);
|
|
508
516
|
if (run.postmortem) sections.push("## Post-mortem", "", run.postmortem);
|
|
@@ -535,8 +543,11 @@ export function writeRunGraph(run: HordeRun): void {
|
|
|
535
543
|
}
|
|
536
544
|
}
|
|
537
545
|
|
|
538
|
-
/**
|
|
539
|
-
|
|
546
|
+
/**
|
|
547
|
+
* Wind a run down once every member has exited: reconcile, report, synthesize, notify. The
|
|
548
|
+
* swarm's slot frees once every horde has reported, and the swarm step follows the last finalize.
|
|
549
|
+
*/
|
|
550
|
+
export async function finalizeRun(state: HordeState, swarm: Swarm, run: HordeRun): Promise<void> {
|
|
540
551
|
const now = Date.now();
|
|
541
552
|
try {
|
|
542
553
|
if (state.disposed) return;
|
|
@@ -552,21 +563,20 @@ export async function finalizeRun(state: HordeState, run: HordeRun): Promise<voi
|
|
|
552
563
|
(acceptance.issues.length > 0 || supersededCitations(answer.proposal.text, ledgers.findings).length > 0);
|
|
553
564
|
if (annotate) annotateAnswer(run, answer, acceptance, ledgers.findings);
|
|
554
565
|
// Workers and bounded repairs have exited; only now may their labs be torn down.
|
|
555
|
-
const cleanups = await runCleanups(
|
|
566
|
+
const cleanups = await runCleanups(run);
|
|
556
567
|
const reportInput = writeRunReports(run, ledgers, releasedIdeas, cleanups, now);
|
|
557
568
|
// Free the slot before the (possibly slow) synthesis so a new run can start.
|
|
558
|
-
|
|
569
|
+
noteRunReported(state, swarm, run);
|
|
559
570
|
await synthesize(state, run, ledgers, reportInput, now);
|
|
560
571
|
if (annotate && run.deliverable) annotateAnswer(run, answer, acceptance, ledgers.findings);
|
|
561
|
-
sendReport(state, run, ledgers, now);
|
|
572
|
+
sendReport(state, swarm, run, ledgers, now);
|
|
562
573
|
await recordLifecycle(run, "run-end", { outcome: runOutcome(run), elapsedMs: Date.now() - run.startedAt, stopLabel: run.stopLabel });
|
|
563
574
|
writeRunGraph(run);
|
|
564
575
|
} catch (error) {
|
|
565
576
|
safeNotify(run, `horde ${run.id} failed to finalize: ${errorMessage(error)}`, "error");
|
|
566
577
|
} finally {
|
|
567
|
-
|
|
578
|
+
noteRunReported(state, swarm, run);
|
|
568
579
|
run.finalizing = undefined;
|
|
569
|
-
|
|
570
|
-
unmountWidget(run);
|
|
580
|
+
await noteRunFinalized(state, swarm, run);
|
|
571
581
|
}
|
|
572
582
|
}
|