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.
@@ -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
- /** The non-owner with the fewest open review requests; ties go to the verifier, then roster order. */
28
- function pickReviewer(launch: RunLaunch, load: Map<string, number>, owner: string, liveOnly: boolean): MemberState | undefined {
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) => count(a) - count(b) || Number(b.name === run.verifier) - Number(a.name === run.verifier))[0];
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 reviewer = pickReviewer(launch, load, artifact.owner, options.liveOnly);
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. The review closes this parent assignment; a failed/partial check opens a challenge to the owner.",
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 automatically would
79
- * carry acceptance to content nobody reviewed against this artifact, so the owner is asked to
80
- * republish against the successor instead.
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
- if (!moved.length) continue;
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: `Dependencies of ${artifact.id} were superseded (${moved.join(", ")}). Check the corrected findings, update ${artifact.path} ` +
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 { unverifiedFindings } from "../ledger/findings.js";
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
- function dropContested(run: HordeRun, contest: SolvedSignal): void {
50
- safeNotify(run, `horde ${run.id}: solved proposal contested by ${contest.member}`, "warning");
51
- void recordLifecycle(run, "solved-contested", { member: contest.member, text: oneLine(contest.text, 200) });
52
- void postBoard(
53
- run.hordeDir,
54
- "parent",
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) adoptProposal(run, 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) return;
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
- const othersRunning = run.members.some(
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,
@@ -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
- function resetReasonForExit(launch: RunLaunch, member: MemberState): string | undefined {
84
- const { state, run } = launch;
85
- if (state.disposed || run.stopped || run.userStop || run.solved || run.canary !== "ok") {
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
- if (!reason) return undefined;
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
- `reset ${member.name} (${reason}): released ${releasedText}; closed ${closedText}. ` +
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
- await performReset(run, member, reason);
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 scope = `checked: ${str(review.checked) ?? ""}; unchecked: ${str(review.unchecked) || "none declared"}`;
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)})` : ""}`];