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/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;
|
|
@@ -25,7 +26,12 @@ const NO_VERIFIER_COMPLETION = {
|
|
|
25
26
|
description: "do not assign the verifier role to the last member of a 3+ member run",
|
|
26
27
|
};
|
|
27
28
|
|
|
28
|
-
const
|
|
29
|
+
const UNTIL_STOP_COMPLETION = {
|
|
30
|
+
value: "--until-stop ",
|
|
31
|
+
description: "keep the whole horde working until stop-clean or stop-hard; validated solves are banked",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const LAUNCH_FLAG_COMPLETIONS = [DASHBOARD_COMPLETION, NO_VERIFIER_COMPLETION, UNTIL_STOP_COMPLETION];
|
|
29
35
|
|
|
30
36
|
/** Argument completions that are not member targets. */
|
|
31
37
|
const EXTRA_COMPLETIONS: Record<string, Array<{ value: string; description: string }>> = {
|
|
@@ -36,7 +42,24 @@ const EXTRA_COMPLETIONS: Record<string, Array<{ value: string; description: stri
|
|
|
36
42
|
plan: [DASHBOARD_COMPLETION],
|
|
37
43
|
};
|
|
38
44
|
|
|
39
|
-
|
|
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 {
|
|
40
63
|
const space = prefix.search(/\s/);
|
|
41
64
|
if (space === -1 && prefix.startsWith("-")) {
|
|
42
65
|
const items = LAUNCH_FLAG_COMPLETIONS.filter((flag) => flag.value.startsWith(prefix)).map(
|
|
@@ -61,18 +84,12 @@ export function controlCompletions(prefix: string, active: HordeRun | undefined)
|
|
|
61
84
|
const match = /^([\s\S]*?)([^\s,]*)$/.exec(rest);
|
|
62
85
|
const base = match?.[1] ?? "";
|
|
63
86
|
const token = match?.[2] ?? "";
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
description:
|
|
71
|
-
name === "all"
|
|
72
|
-
? active
|
|
73
|
-
? `all ${active.count} members`
|
|
74
|
-
: "all members"
|
|
75
|
-
: 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,
|
|
76
93
|
}));
|
|
77
94
|
const extraItems: AutocompleteItem[] = (EXTRA_COMPLETIONS[head] ?? [])
|
|
78
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 } from "../ledger/findings.js";
|
|
23
|
+
import { listFindingsForRun, parseFindings, supersededCitations } 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;
|
|
@@ -109,6 +110,11 @@ function settleSolved(run: HordeRun): SettledAnswer | undefined {
|
|
|
109
110
|
) {
|
|
110
111
|
run.solvedValidatedBy = solvedState.confirm.member;
|
|
111
112
|
}
|
|
113
|
+
// `--until-stop`: the last banked answer stands unless a later proposal was validated too.
|
|
114
|
+
if (run.lastRound && !(run.solved && run.solvedValidatedBy)) {
|
|
115
|
+
run.solved = run.lastRound.proposal;
|
|
116
|
+
run.solvedValidatedBy = run.lastRound.validator;
|
|
117
|
+
}
|
|
112
118
|
if (run.solved) {
|
|
113
119
|
const status = run.solvedValidatedBy
|
|
114
120
|
? `validated by ${run.solvedValidatedBy}`
|
|
@@ -132,12 +138,6 @@ interface SettledAnswer {
|
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
/** Findings the proposal names that a later finding corrected; labeled, not re-versioned. */
|
|
135
|
-
export function supersededCitations(text: string, findings: FindingEntry[]): string[] {
|
|
136
|
-
return findings
|
|
137
|
-
.filter((finding) => finding.supersededBy !== undefined && new RegExp(`\\bF${finding.id}\\b`).test(text))
|
|
138
|
-
.map((finding) => `F${finding.id} (now F${finding.supersededBy})`);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
141
|
/**
|
|
142
142
|
* The proposal body is the member's verbatim text and can predate later challenges and corrections.
|
|
143
143
|
* The status names the blockers still open at freeze and the findings a later finding superseded,
|
|
@@ -280,12 +280,11 @@ async function runCleanup(run: HordeRun, item: PendingCleanup): Promise<CleanupR
|
|
|
280
280
|
* Tear down the labs this run registered before writing the report, so the
|
|
281
281
|
* report states whether cleanup succeeded and no containers outlive the run.
|
|
282
282
|
*/
|
|
283
|
-
async function runCleanups(
|
|
283
|
+
async function runCleanups(run: HordeRun): Promise<CleanupResult[]> {
|
|
284
284
|
const pending =
|
|
285
285
|
process.env.PI_HORDE_CLEANUP === "off" ? [] : pendingCleanups(run.hordeDir, run.id);
|
|
286
286
|
if (pending.length === 0) return [];
|
|
287
287
|
run.finalizing = ["cleanup"];
|
|
288
|
-
state.finalizing = run;
|
|
289
288
|
refreshRun(run);
|
|
290
289
|
const cleanups: CleanupResult[] = [];
|
|
291
290
|
for (const item of pending) cleanups.push(await runCleanup(run, item));
|
|
@@ -338,6 +337,7 @@ function writeRunReports(
|
|
|
338
337
|
});
|
|
339
338
|
const memberPosts = lastBoardPostsByMember(run.hordeDir, run.id);
|
|
340
339
|
const outcome = runOutcome(run);
|
|
340
|
+
run.outcome = outcome;
|
|
341
341
|
let events: HordeEvent[] = [];
|
|
342
342
|
try {
|
|
343
343
|
events = readEvents(run.hordeDir, run.id);
|
|
@@ -462,7 +462,6 @@ async function synthesize(
|
|
|
462
462
|
if (wantPostmortem) steps.push("post-mortem");
|
|
463
463
|
if (steps.length > 0) {
|
|
464
464
|
run.finalizing = steps;
|
|
465
|
-
state.finalizing = run;
|
|
466
465
|
refreshRun(run);
|
|
467
466
|
}
|
|
468
467
|
run.deliverable = undefined;
|
|
@@ -503,7 +502,15 @@ async function synthesize(
|
|
|
503
502
|
}
|
|
504
503
|
}
|
|
505
504
|
|
|
506
|
-
|
|
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
|
+
}
|
|
507
514
|
const sections = [reportText(run, ledgers.ideaCounts, ledgers.findings.length, now)];
|
|
508
515
|
if (run.deliverable) sections.push("## Deliverable", "", run.deliverable);
|
|
509
516
|
if (run.postmortem) sections.push("## Post-mortem", "", run.postmortem);
|
|
@@ -536,8 +543,11 @@ export function writeRunGraph(run: HordeRun): void {
|
|
|
536
543
|
}
|
|
537
544
|
}
|
|
538
545
|
|
|
539
|
-
/**
|
|
540
|
-
|
|
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> {
|
|
541
551
|
const now = Date.now();
|
|
542
552
|
try {
|
|
543
553
|
if (state.disposed) return;
|
|
@@ -553,21 +563,20 @@ export async function finalizeRun(state: HordeState, run: HordeRun): Promise<voi
|
|
|
553
563
|
(acceptance.issues.length > 0 || supersededCitations(answer.proposal.text, ledgers.findings).length > 0);
|
|
554
564
|
if (annotate) annotateAnswer(run, answer, acceptance, ledgers.findings);
|
|
555
565
|
// Workers and bounded repairs have exited; only now may their labs be torn down.
|
|
556
|
-
const cleanups = await runCleanups(
|
|
566
|
+
const cleanups = await runCleanups(run);
|
|
557
567
|
const reportInput = writeRunReports(run, ledgers, releasedIdeas, cleanups, now);
|
|
558
568
|
// Free the slot before the (possibly slow) synthesis so a new run can start.
|
|
559
|
-
|
|
569
|
+
noteRunReported(state, swarm, run);
|
|
560
570
|
await synthesize(state, run, ledgers, reportInput, now);
|
|
561
571
|
if (annotate && run.deliverable) annotateAnswer(run, answer, acceptance, ledgers.findings);
|
|
562
|
-
sendReport(state, run, ledgers, now);
|
|
572
|
+
sendReport(state, swarm, run, ledgers, now);
|
|
563
573
|
await recordLifecycle(run, "run-end", { outcome: runOutcome(run), elapsedMs: Date.now() - run.startedAt, stopLabel: run.stopLabel });
|
|
564
574
|
writeRunGraph(run);
|
|
565
575
|
} catch (error) {
|
|
566
576
|
safeNotify(run, `horde ${run.id} failed to finalize: ${errorMessage(error)}`, "error");
|
|
567
577
|
} finally {
|
|
568
|
-
|
|
578
|
+
noteRunReported(state, swarm, run);
|
|
569
579
|
run.finalizing = undefined;
|
|
570
|
-
|
|
571
|
-
unmountWidget(run);
|
|
580
|
+
await noteRunFinalized(state, swarm, run);
|
|
572
581
|
}
|
|
573
582
|
}
|