agent-horde 0.1.5 → 0.1.6
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 +21 -8
- package/docs/REFERENCE.md +55 -16
- package/package.json +1 -1
- package/src/args.ts +18 -3
- package/src/build-info.ts +65 -0
- package/src/collaboration-report.ts +23 -9
- package/src/dashboard-html.ts +11 -3
- package/src/ledger/collaboration.ts +36 -6
- package/src/ledger/facts.ts +52 -5
- package/src/ledger/findings.ts +91 -1
- package/src/ledger/run-signals.ts +17 -2
- package/src/member.ts +15 -3
- package/src/prompt.ts +73 -16
- package/src/run/completions.ts +6 -1
- package/src/run/finalize.ts +6 -7
- package/src/run/launch.ts +1 -1
- package/src/run/repair.ts +15 -7
- package/src/run/schedule.ts +43 -13
- package/src/run/setup.ts +2 -1
- package/src/run/solved.ts +125 -15
- package/src/run/supervise.ts +74 -9
- package/src/run/types.ts +6 -0
- package/src/run-graph.ts +9 -2
- package/src/tool/actions.ts +149 -13
- package/src/tool/collaboration-actions.ts +75 -25
- package/src/tool/params.ts +10 -7
- package/src/tool/register.ts +11 -6
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
|
@@ -91,7 +91,7 @@ export function createRun(
|
|
|
91
91
|
fs.writeFileSync(
|
|
92
92
|
path.join(runDir, "TASK.md"),
|
|
93
93
|
taskMarkdown({
|
|
94
|
-
id, prompt, count, model, thinkingLevel: ctx.thinkingLevel, startedAt, verifier,
|
|
94
|
+
id, prompt, count, model, thinkingLevel: ctx.thinkingLevel, startedAt, verifier, untilStop: parsed.untilStop,
|
|
95
95
|
}),
|
|
96
96
|
"utf-8",
|
|
97
97
|
);
|
|
@@ -112,6 +112,7 @@ export function createRun(
|
|
|
112
112
|
model,
|
|
113
113
|
plan,
|
|
114
114
|
verifier,
|
|
115
|
+
untilStop: parsed.untilStop === true ? true : undefined,
|
|
115
116
|
resets: [],
|
|
116
117
|
dashboardUrl,
|
|
117
118
|
};
|
package/src/run/solved.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { oneLine } from "../format.js";
|
|
|
3
3
|
import { loadCollaborationReport } from "../collaboration-report.js";
|
|
4
4
|
import { openMessages } from "../ledger/collaboration.js";
|
|
5
5
|
import { postBoard } from "../ledger/board.js";
|
|
6
|
-
import {
|
|
6
|
+
import { unsettledFactCitations } from "../ledger/facts.js";
|
|
7
|
+
import { listFindingsForRun, staleAnswerCitations, unverifiedFindings } from "../ledger/findings.js";
|
|
7
8
|
import { listIdeas } from "../ledger/ideas.js";
|
|
8
|
-
import { archiveSolved, clearCleanStop, clearSolved, readSolvedState, resolveSolvedState } from "../ledger/run-signals.js";
|
|
9
|
+
import { archiveSolved, clearCleanStop, clearSolved, readSolvedState, reopenSolved, resolveSolvedState, writeAnswer } from "../ledger/run-signals.js";
|
|
9
10
|
import type { SolvedSignal } from "../ledger/run-signals.js";
|
|
10
11
|
import { requestCleanStop } from "./controls.js";
|
|
11
12
|
import { recordLifecycle } from "./lifecycle.js";
|
|
@@ -46,14 +47,12 @@ function adoptProposal(run: HordeRun, proposal: SolvedSignal): void {
|
|
|
46
47
|
refreshRun(run);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
`SOLVED proposal contested by ${contest.member}: ${contest.text}. Work continues.`,
|
|
56
|
-
);
|
|
50
|
+
/** Idea ids a contest names as what is still missing; the proposal reopens once they close. */
|
|
51
|
+
export function contestBlockers(text: string): number[] {
|
|
52
|
+
return [...new Set([...text.matchAll(/(?:^|[\s(,;])#(\d{1,6})\b/g)].map((m) => Number(m[1])))];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clearProposal(run: HordeRun): void {
|
|
57
56
|
archiveSolved(run.runDir);
|
|
58
57
|
clearSolved(run.runDir);
|
|
59
58
|
if (!run.stopped) clearCleanStop(run.runDir);
|
|
@@ -62,10 +61,76 @@ function dropContested(run: HordeRun, contest: SolvedSignal): void {
|
|
|
62
61
|
run.solvedCommitted = false;
|
|
63
62
|
run.solvedCoverageNotice = undefined;
|
|
64
63
|
run.solvedVerificationNotice = undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function dropContested(run: HordeRun, proposal: SolvedSignal, contest: SolvedSignal): void {
|
|
67
|
+
const blockers = contestBlockers(contest.text);
|
|
68
|
+
safeNotify(run, `horde ${run.id}: solved proposal contested by ${contest.member}`, "warning");
|
|
69
|
+
void recordLifecycle(run, "solved-contested", { member: contest.member, text: oneLine(contest.text, 200), blockers });
|
|
70
|
+
const reopens = blockers.length ? ` It reopens for validation once ${blockers.map((id) => `#${id}`).join(", ")} are done or ruled out.` : "";
|
|
71
|
+
void postBoard(
|
|
72
|
+
run.hordeDir,
|
|
73
|
+
"parent",
|
|
74
|
+
`SOLVED proposal contested by ${contest.member}: ${contest.text}. Work continues.${reopens}`,
|
|
75
|
+
);
|
|
76
|
+
clearProposal(run);
|
|
65
77
|
run.solvedContested = true;
|
|
78
|
+
run.solvedContest = blockers.length ? { proposal, blockers } : undefined;
|
|
66
79
|
refreshRun(run);
|
|
67
80
|
}
|
|
68
81
|
|
|
82
|
+
/**
|
|
83
|
+
* A proposal that cites a finding superseded or amended after it was made would ship the stale
|
|
84
|
+
* reading as the answer. It is withdrawn for re-proposal rather than validated; the board names
|
|
85
|
+
* the successor or the amendment.
|
|
86
|
+
*/
|
|
87
|
+
function withdrawStale(run: HordeRun, proposal: SolvedSignal, stale: string[]): void {
|
|
88
|
+
const reason = `cites ${stale.join(", ")}`;
|
|
89
|
+
safeNotify(run, `horde ${run.id}: solved proposal withdrawn; it ${reason}`, "warning");
|
|
90
|
+
void recordLifecycle(run, "solved-withdrawn", { member: proposal.member, reason });
|
|
91
|
+
void postBoard(
|
|
92
|
+
run.hordeDir,
|
|
93
|
+
"parent",
|
|
94
|
+
`SOLVED proposal by ${proposal.member} withdrawn: it ${reason}. ${proposal.member}: propose again citing the current findings.`,
|
|
95
|
+
);
|
|
96
|
+
clearProposal(run);
|
|
97
|
+
refreshRun(run);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A contest that named open ideas as what was missing is answered by their closing: nothing else
|
|
102
|
+
* reopens the vote, so the run ended `solved-contested` with the closed negatives unapplied.
|
|
103
|
+
*/
|
|
104
|
+
function reopenContested(run: HordeRun): void {
|
|
105
|
+
const contest = run.solvedContest;
|
|
106
|
+
if (!contest || run.stopped) return;
|
|
107
|
+
const closed = new Map(listIdeas(run.hordeDir).map((idea) => [idea.id, idea.mark === "x" || idea.mark === "!"]));
|
|
108
|
+
if (!contest.blockers.every((id) => closed.get(id))) return;
|
|
109
|
+
const named = contest.blockers.map((id) => `#${id}`).join(", ");
|
|
110
|
+
const reason = `contest blockers ${named} closed`;
|
|
111
|
+
// The proposal returns with the citations it pinned when it was made; what went stale or
|
|
112
|
+
// unsettled meanwhile makes it a re-proposal for its author, not a reopening.
|
|
113
|
+
const stale = [
|
|
114
|
+
...staleAnswerCitations(listFindingsForRun(run.hordeDir, run.id), contest.proposal.text, contest.proposal.cites),
|
|
115
|
+
...unsettledFactCitations(run.hordeDir, contest.proposal.text).map((citation) => `provisional ${citation}`),
|
|
116
|
+
];
|
|
117
|
+
run.solvedContest = undefined;
|
|
118
|
+
if (stale.length) {
|
|
119
|
+
const why = `cites ${stale.join(", ")}`;
|
|
120
|
+
void recordLifecycle(run, "solved-withdrawn", { member: contest.proposal.member, reason: why });
|
|
121
|
+
void postBoard(run.hordeDir, "parent", `SOLVED proposal by ${contest.proposal.member} is not re-opened after ${reason}: it ${why}. ${contest.proposal.member}: propose again citing the current findings.`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const signal = reopenSolved(run.runDir, run.id, contest.proposal, `re-opened: ${reason}; awaiting validation`, contest.proposal.cites);
|
|
125
|
+
run.solvedContested = false;
|
|
126
|
+
void recordLifecycle(run, "solved-reopened", { member: signal.member, reason, revision: signal.revision });
|
|
127
|
+
void postBoard(
|
|
128
|
+
run.hordeDir,
|
|
129
|
+
"parent",
|
|
130
|
+
`SOLVED proposal by ${signal.member} re-opened: ${reason}. Another member must validate it against the closed ideas with 'horde confirm-solved' or contest it again.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
69
134
|
/**
|
|
70
135
|
* Coverage is structural: a run that created ideas must close them before a
|
|
71
136
|
* solve winds it down, regardless of the prompt's wording. A run that created
|
|
@@ -124,11 +189,43 @@ function verificationPending(run: HordeRun, othersRunning: boolean): boolean {
|
|
|
124
189
|
return true;
|
|
125
190
|
}
|
|
126
191
|
|
|
192
|
+
/**
|
|
193
|
+
* `--until-stop`: bank the answer as the run's current one and reopen the proposal slot, so a
|
|
194
|
+
* materially better answer can be proposed later. Nobody is stopped; the supervisor gives every
|
|
195
|
+
* member that finishes a fresh session.
|
|
196
|
+
*/
|
|
197
|
+
function bankSolved(run: HordeRun, proposal: SolvedSignal, validator: string | undefined): void {
|
|
198
|
+
run.solveRounds = (run.solveRounds ?? 0) + 1;
|
|
199
|
+
run.lastRound = { proposal, validator };
|
|
200
|
+
const status = validator ? `validated by ${validator}` : "committed without a separate validator";
|
|
201
|
+
try {
|
|
202
|
+
writeAnswer(run.runDir, run.id, proposal, `${status} (banked as round ${run.solveRounds}; the run continues until stopped)`);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
safeNotify(run, `horde ${run.id}: could not write ANSWER.md (${oneLine(String(error), 200)})`, "error");
|
|
205
|
+
}
|
|
206
|
+
clearProposal(run);
|
|
207
|
+
run.solvedContest = undefined;
|
|
208
|
+
void recordLifecycle(run, "solved-banked", { round: run.solveRounds, member: proposal.member, validator });
|
|
209
|
+
safeNotify(run, `horde ${run.id}: solve banked as round ${run.solveRounds}; the run continues until stopped`, "info");
|
|
210
|
+
void postBoard(
|
|
211
|
+
run.hordeDir,
|
|
212
|
+
"parent",
|
|
213
|
+
`SOLVED ${status}, banked as round ${run.solveRounds}: ${oneLine(proposal.text, 200)}. ` +
|
|
214
|
+
"This run continues until the user stops it. Keep working: extend, stress-test, or deepen the work, " +
|
|
215
|
+
"add ideas for what is still unexplored, and propose SOLVED again only when a materially better answer exists.",
|
|
216
|
+
);
|
|
217
|
+
refreshRun(run);
|
|
218
|
+
}
|
|
219
|
+
|
|
127
220
|
function commitSolved(
|
|
128
221
|
run: HordeRun,
|
|
129
222
|
ctx: ExtensionCommandContext,
|
|
130
223
|
validator: string | undefined,
|
|
131
224
|
): void {
|
|
225
|
+
if (run.untilStop && run.solved && !run.stopped) {
|
|
226
|
+
bankSolved(run, run.solved, validator);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
132
229
|
run.solvedValidatedBy = validator;
|
|
133
230
|
run.solvedCommitted = true;
|
|
134
231
|
void recordLifecycle(run, "solved-validated", { validator });
|
|
@@ -150,13 +247,24 @@ function commitSolved(
|
|
|
150
247
|
}
|
|
151
248
|
|
|
152
249
|
/** One poll of the two-phase solved protocol. */
|
|
153
|
-
function checkSolved(run: HordeRun, ctx: ExtensionCommandContext): void {
|
|
250
|
+
export function checkSolved(run: HordeRun, ctx: ExtensionCommandContext): void {
|
|
154
251
|
const state = resolveSolvedState(readSolvedState(run.runDir));
|
|
155
|
-
if (!run.solved && state.proposal)
|
|
252
|
+
if (!run.solved && state.proposal) {
|
|
253
|
+
run.solvedContest = undefined;
|
|
254
|
+
adoptProposal(run, state.proposal);
|
|
255
|
+
}
|
|
156
256
|
const solved = run.solved;
|
|
157
|
-
if (!solved)
|
|
257
|
+
if (!solved) {
|
|
258
|
+
reopenContested(run);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
158
261
|
if (state.contest) {
|
|
159
|
-
dropContested(run, state.contest);
|
|
262
|
+
dropContested(run, solved, state.contest);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const stale = staleAnswerCitations(listFindingsForRun(run.hordeDir, run.id), solved.text, solved.cites);
|
|
266
|
+
if (stale.length && !run.solvedCommitted) {
|
|
267
|
+
withdrawStale(run, solved, stale);
|
|
160
268
|
return;
|
|
161
269
|
}
|
|
162
270
|
const validator =
|
|
@@ -167,7 +275,9 @@ function checkSolved(run: HordeRun, ctx: ExtensionCommandContext): void {
|
|
|
167
275
|
if (coveragePending(run, validator)) return;
|
|
168
276
|
const collaboration = loadCollaborationReport(run.hordeDir, run.id);
|
|
169
277
|
if (collaboration.issues.length || openMessages(collaboration.state).length) return;
|
|
170
|
-
|
|
278
|
+
// Members of an `--until-stop` run are always coming back, so a lull between sessions is not
|
|
279
|
+
// the absence of a validator; the proposal waits for one or for the timeout.
|
|
280
|
+
const othersRunning = run.untilStop === true || run.members.some(
|
|
171
281
|
(member) =>
|
|
172
282
|
(member.status === "running" || member.status === "starting") &&
|
|
173
283
|
member.name !== solved.member,
|
package/src/run/supervise.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { envNumber, errorMessage } from "../format.js";
|
|
6
6
|
import { postBoard } from "../ledger/board.js";
|
|
7
|
+
import { pendingReviewsForMember } from "../ledger/findings.js";
|
|
7
8
|
import { reconcileOwnedIdeas } from "../ledger/ideas.js";
|
|
8
9
|
import { cleanStopRequestedFor, clearHeartbeat, readHeartbeat } from "../ledger/run-signals.js";
|
|
9
10
|
import { resetMemberState, spawnMember } from "../member.js";
|
|
@@ -22,6 +23,17 @@ const RESET_CAP = envNumber("PI_HORDE_RESET_CAP", 2);
|
|
|
22
23
|
/** Member exits that a fresh session may recover from; a user stop is never one. */
|
|
23
24
|
const RESETTABLE_CAUSES = new Set(["model_error", "no_output", "exit_nonzero"]);
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* An `--until-stop` session that finished sooner than this is held back until it has been this
|
|
28
|
+
* long since it started, so a member that keeps finding nothing to do cannot spin through
|
|
29
|
+
* sessions, each paying the full prompt for nothing.
|
|
30
|
+
*/
|
|
31
|
+
const CONTINUE_COOLDOWN_MS = envNumber("PI_HORDE_CONTINUE_COOLDOWN_MS", 60_000);
|
|
32
|
+
|
|
33
|
+
const CONTINUE_REASON = "until-stop: earlier session finished";
|
|
34
|
+
|
|
35
|
+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms).unref?.());
|
|
36
|
+
|
|
25
37
|
/** What spawning and supervising a run's members needs beyond the run itself. */
|
|
26
38
|
export interface RunLaunch {
|
|
27
39
|
state: HordeState;
|
|
@@ -80,16 +92,34 @@ export async function spawnOne(
|
|
|
80
92
|
* stop all mean the run is ending; the cap keeps a broken provider from being
|
|
81
93
|
* retried forever.
|
|
82
94
|
*/
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
95
|
+
/**
|
|
96
|
+
* A verifier that closes its session with high-importance findings by other members unreviewed
|
|
97
|
+
* has not done its job, whatever its summary says. Its exit is answered with a fresh session
|
|
98
|
+
* that is told which F# it owes, within the same reset cap as a crash.
|
|
99
|
+
*/
|
|
100
|
+
export function verificationBacklog(run: HordeRun, member: MemberState): string | undefined {
|
|
101
|
+
if (member.name !== run.verifier || member.endCause !== "completed") return undefined;
|
|
102
|
+
try {
|
|
103
|
+
const owed = pendingReviewsForMember(run.hordeDir, run.id, member.name)
|
|
104
|
+
.filter((finding) => ["critical", "high"].includes((finding.importance ?? "").toLowerCase()));
|
|
105
|
+
if (!owed.length) return undefined;
|
|
106
|
+
return `verification backlog: ${owed.map((finding) => `F${finding.id}`).join(", ")} unreviewed at close`;
|
|
107
|
+
} catch {
|
|
86
108
|
return undefined;
|
|
87
109
|
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resetReasonForExit(launch: RunLaunch, member: MemberState): string | undefined {
|
|
113
|
+
const { state, run } = launch;
|
|
114
|
+
if (state.disposed || run.stopped || run.userStop || run.canary !== "ok") return undefined;
|
|
115
|
+
// A pending proposal means the run is about to end, unless it runs until stopped.
|
|
116
|
+
if (run.solved && !run.untilStop) return undefined;
|
|
88
117
|
if (cleanStopRequestedFor(run.hordeDir, run.id, member.name)) return undefined;
|
|
89
118
|
const reason =
|
|
90
119
|
member.resetPending ??
|
|
91
|
-
(RESETTABLE_CAUSES.has(member.endCause ?? "") ? member.endCause : undefined)
|
|
92
|
-
|
|
120
|
+
(RESETTABLE_CAUSES.has(member.endCause ?? "") ? member.endCause : undefined) ??
|
|
121
|
+
verificationBacklog(run, member);
|
|
122
|
+
if (!reason) return run.untilStop && member.endCause === "completed" ? CONTINUE_REASON : undefined;
|
|
93
123
|
if ((member.resets ?? 0) >= RESET_CAP) {
|
|
94
124
|
fs.appendFileSync(
|
|
95
125
|
path.join(run.runDir, `${member.name}.log`),
|
|
@@ -115,10 +145,13 @@ async function releaseForReset(
|
|
|
115
145
|
const releasedText = released.map((id) => `#${id}`).join(", ") || "none";
|
|
116
146
|
const closedText =
|
|
117
147
|
result.closed.map((item) => `#${item.id} (F${item.findingId})`).join(", ") || "none";
|
|
148
|
+
const head = reason === CONTINUE_REASON
|
|
149
|
+
? `${member.name} finished and continues in a fresh session (${run.id} runs until stopped)`
|
|
150
|
+
: `reset ${member.name} (${reason})`;
|
|
118
151
|
await postBoard(
|
|
119
152
|
run.hordeDir,
|
|
120
153
|
"parent",
|
|
121
|
-
|
|
154
|
+
`${head}: released ${releasedText}; closed ${closedText}. ` +
|
|
122
155
|
`A fresh session of ${member.name} starts now; earlier claims by ${member.name} ` +
|
|
123
156
|
"are void.",
|
|
124
157
|
);
|
|
@@ -157,13 +190,41 @@ async function performReset(run: HordeRun, member: MemberState, reason: string):
|
|
|
157
190
|
await recordLifecycle(run, "member-reset", { member: member.name, reason, released, closed, resets: member.resets });
|
|
158
191
|
member.resetNote =
|
|
159
192
|
`You replace an earlier session of ${member.name} that was reset for ${reason}; ` +
|
|
160
|
-
"its ideas were released. Read BOARD.md and FINDINGS.md before claiming."
|
|
193
|
+
"its ideas were released. Read BOARD.md and FINDINGS.md before claiming." +
|
|
194
|
+
(reason.startsWith("verification backlog") ? " Review the findings named above, with any verdict and an honest unchecked scope, before anything else." : "");
|
|
161
195
|
clearHeartbeat(run.runDir, member.name);
|
|
162
196
|
resetMemberState(member);
|
|
163
197
|
safeNotify(run, `horde ${run.id}: reset ${member.name} (${reason})`, "warning");
|
|
164
198
|
refreshRun(run);
|
|
165
199
|
}
|
|
166
200
|
|
|
201
|
+
/**
|
|
202
|
+
* `--until-stop`: a member that finished its session gets a fresh one. Not a recovery, so it
|
|
203
|
+
* counts against no cap and is not a reset in the report; the cooldown keeps a member that
|
|
204
|
+
* finds nothing to do from spinning.
|
|
205
|
+
*/
|
|
206
|
+
async function performContinue(run: HordeRun, member: MemberState): Promise<boolean> {
|
|
207
|
+
const elapsed = Date.now() - member.startedAt;
|
|
208
|
+
if (elapsed < CONTINUE_COOLDOWN_MS) await sleep(CONTINUE_COOLDOWN_MS - elapsed);
|
|
209
|
+
// A stop during the cooldown leaves the member as it ended: finished, not killed.
|
|
210
|
+
if (run.stopped) return false;
|
|
211
|
+
member.continuations = (member.continuations ?? 0) + 1;
|
|
212
|
+
fs.appendFileSync(
|
|
213
|
+
path.join(run.runDir, `${member.name}.log`),
|
|
214
|
+
`[horde] continue ${member.continuations}: ${CONTINUE_REASON}\n`,
|
|
215
|
+
);
|
|
216
|
+
const { released, closed } = await releaseForReset(run, member, CONTINUE_REASON);
|
|
217
|
+
await recordLifecycle(run, "member-continue", { member: member.name, released, closed, continuations: member.continuations });
|
|
218
|
+
member.resetNote =
|
|
219
|
+
`You are a fresh session of ${member.name}; its earlier session finished and its claims were released. ` +
|
|
220
|
+
"Read BOARD.md, FINDINGS.md and ANSWER.md if present, then add ideas that extend, stress-test, or deepen the work, claim them, and pursue them.";
|
|
221
|
+
clearHeartbeat(run.runDir, member.name);
|
|
222
|
+
resetMemberState(member);
|
|
223
|
+
safeNotify(run, `horde ${run.id}: ${member.name} finished; continuing in a fresh session`, "info");
|
|
224
|
+
refreshRun(run);
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
167
228
|
/**
|
|
168
229
|
* Resolves only when the member is down for good, so the fan-out's
|
|
169
230
|
* Promise.all cannot finalize the run while a replacement is running.
|
|
@@ -179,7 +240,11 @@ export async function superviseMember(
|
|
|
179
240
|
await exited;
|
|
180
241
|
const reason = resetReasonForExit(launch, member);
|
|
181
242
|
if (!reason || state.disposed) return;
|
|
182
|
-
|
|
243
|
+
if (reason === CONTINUE_REASON) {
|
|
244
|
+
if (!(await performContinue(run, member))) return;
|
|
245
|
+
} else {
|
|
246
|
+
await performReset(run, member, reason);
|
|
247
|
+
}
|
|
183
248
|
if (run.stopped || state.disposed) {
|
|
184
249
|
// A stop landed during the reset: leave the member down, not "starting".
|
|
185
250
|
member.status = "stopped";
|
|
@@ -196,7 +261,7 @@ export async function superviseMember(
|
|
|
196
261
|
* hung. Mark it and kill it; the supervise loop turns the kill into a reset.
|
|
197
262
|
*/
|
|
198
263
|
function checkStalls(run: HordeRun): void {
|
|
199
|
-
if (run.stopped || run.solved) return;
|
|
264
|
+
if (run.stopped || (run.solved && !run.untilStop)) return;
|
|
200
265
|
const now = Date.now();
|
|
201
266
|
for (const member of run.members) {
|
|
202
267
|
if (member.status !== "running" || member.paused || member.resetPending) continue;
|
package/src/run/types.ts
CHANGED
|
@@ -67,8 +67,14 @@ export interface HordeRun extends MemberRunInfo {
|
|
|
67
67
|
solvedCommitted?: boolean | undefined;
|
|
68
68
|
/** True when the last SOLVED proposal was contested and no validated answer replaced it. */
|
|
69
69
|
solvedContested?: boolean | undefined;
|
|
70
|
+
/** The contested proposal and the idea ids its contest named; it reopens once they all close. */
|
|
71
|
+
solvedContest?: { proposal: SolvedSignal; blockers: number[] } | undefined;
|
|
70
72
|
/** True once the parent has warned that a validated solve still has unverified findings. */
|
|
71
73
|
solvedVerificationNotice?: boolean | undefined;
|
|
74
|
+
/** Solves banked so far in an `--until-stop` run. */
|
|
75
|
+
solveRounds?: number | undefined;
|
|
76
|
+
/** The latest banked solve of an `--until-stop` run: the answer unless a later proposal is validated. */
|
|
77
|
+
lastRound?: { proposal: SolvedSignal; validator: string | undefined } | undefined;
|
|
72
78
|
solvedTimer?: ReturnType<typeof setInterval> | undefined;
|
|
73
79
|
solvedCoverageNotice?: boolean | undefined;
|
|
74
80
|
postmortemAbort?: AbortController | undefined;
|
package/src/run-graph.ts
CHANGED
|
@@ -360,10 +360,13 @@ function addReview(draft: Draft, event: HordeEvent, review: Payload): void {
|
|
|
360
360
|
if (!id) return;
|
|
361
361
|
const verdict = str(review.verdict) ?? "review";
|
|
362
362
|
const support = str(review.support) ?? "";
|
|
363
|
-
const
|
|
363
|
+
const limits = str(review.limits) ? `; limits: ${str(review.limits)}` : "";
|
|
364
|
+
const scope = `checked: ${str(review.checked) ?? ""}; unchecked: ${str(review.unchecked) || "none declared"}${limits}`;
|
|
364
365
|
const title = oneLine(`[${support}] ${scope}. ${str(review.text) ?? ""}`, TITLE_MAX);
|
|
365
366
|
if (!addEntity(draft, event, { id, kind: "review", label: `${id} ${verdict}`, title })) return;
|
|
366
367
|
addEdge(draft, id, str(review.target), verdict, relationTone(verdict));
|
|
368
|
+
const reattests = str(review.reattests);
|
|
369
|
+
if (reattests && draft.nodes.has(reattests)) draft.edges.push({ from: reattests, to: id, label: "reattested", tone: "collaboration" });
|
|
367
370
|
for (const ref of strings(review.evidence)) {
|
|
368
371
|
if (draft.nodes.has(ref)) draft.edges.push({ from: ref, to: id, label: "cited", tone: "collaboration" });
|
|
369
372
|
}
|
|
@@ -427,7 +430,7 @@ const COLLABORATION: Record<string, Handler> = {
|
|
|
427
430
|
function lifecycleText(action: string, p: Payload): [string, string] {
|
|
428
431
|
const members = strings(p.members).join(", ");
|
|
429
432
|
switch (action) {
|
|
430
|
-
case "run-start": return ["run start", `${num(p.count) ?? "?"} members${str(p.model) ? `, model ${str(p.model)}` : ""}`];
|
|
433
|
+
case "run-start": return ["run start", `${num(p.count) ?? "?"} members${str(p.model) ? `, model ${str(p.model)}` : ""}${p.untilStop === true ? ", until stop" : ""}`];
|
|
431
434
|
case "run-end": return ["run end", `outcome ${str(p.outcome) ?? "unknown"}`];
|
|
432
435
|
case "member-spawn": return ["spawn", str(p.role) ? `role: ${oneLine(str(p.role) ?? "", 120)}` : "spawned"];
|
|
433
436
|
case "member-exit": return [`exit ${str(p.endCause) ?? ""}`.trim(), `status ${str(p.status) ?? "?"}, ${num(p.turns) ?? 0} turns, ${num(p.tokens) ?? 0} tokens`];
|
|
@@ -436,7 +439,11 @@ function lifecycleText(action: string, p: Payload): [string, string] {
|
|
|
436
439
|
case "stop-hard": return [`stop-hard (${str(p.by) ?? "user"})`, members || "all members"];
|
|
437
440
|
case "solved-proposed": return ["solved proposed", `${str(p.member) ?? ""}: ${oneLine(str(p.text) ?? "", 120)}`];
|
|
438
441
|
case "solved-contested": return ["solved contested", `by ${str(p.member) ?? ""}`];
|
|
442
|
+
case "solved-withdrawn": return ["solved withdrawn", oneLine(str(p.reason) ?? "", 120)];
|
|
443
|
+
case "solved-reopened": return ["solved reopened", oneLine(str(p.reason) ?? "", 120)];
|
|
439
444
|
case "solved-validated": return ["solved validated", str(p.validator) ? `by ${str(p.validator)}` : "without a separate validator"];
|
|
445
|
+
case "solved-banked": return [`solved banked (round ${num(p.round) ?? "?"})`, str(p.validator) ? `by ${str(p.validator)}` : "without a separate validator"];
|
|
446
|
+
case "member-continue": return ["continue", `fresh session ${num(p.continuations) ?? "?"}; released ${strings(p.released).length}, closed ${strings(p.closed).length}`];
|
|
440
447
|
case "acceptance": return [`acceptance ${str(p.status) ?? ""}`.trim(), `${num(p.issues) ?? 0} issues, snapshot ${(str(p.hash) ?? "").slice(0, 12)}`];
|
|
441
448
|
case "repair-start": return ["repair start", `queue ${strings(p.messages).join(", ")}`];
|
|
442
449
|
case "repair-end": return ["repair end", `${str(p.cause) ?? ""}${str(p.stopReason) ? ` (${str(p.stopReason)})` : ""}`];
|