agent-horde 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,7 +90,7 @@ pi install npm:agent-horde
90
90
  Or from the repository, pinned to a tag:
91
91
 
92
92
  ```
93
- pi install git:github.com/eliyastein/agent-horde@v0.2.0
93
+ pi install git:github.com/eliyastein/agent-horde@v0.2.1
94
94
  ```
95
95
 
96
96
  To hack on it, clone and install in place. pi loads the files from the clone, so edits take effect
package/docs/REFERENCE.md CHANGED
@@ -42,7 +42,14 @@ synthesize it.
42
42
  The default totals follow the queues' demand (one floor per queue plus one per further review
43
43
  request or blocking challenge), each attempt's share is cut in proportion to its demand, and
44
44
  every attempt gets at least a floor; the phase start records the budget, `repair-start` the
45
- attempt's demand, and `repair-end` its turns and first-turn cost. A user stop skips this phase.
45
+ attempt's demand, and `repair-end` its turns, first-turn cost and the threads it left
46
+ `unchanged`. A thread that two consecutive completed attempts did not move (same state and
47
+ response revision) is held back from that member until another member changes it; a member
48
+ whose whole queue is held is recorded as `repair-skipped` with reason `no-progress` and costs
49
+ later rounds nothing. Each round serves a member at most once, but a round other than the last
50
+ ends early when the only blocking threads left belong to members it already served, so a
51
+ requester rechecks a correction before unserved members' non-blocking threads.
52
+ A user stop skips this phase.
46
53
  4. **Finalize.** Acceptance is frozen (`ACCEPTANCE.*`, `snapshot/`), resource cleanup commands
47
54
  run, `SUMMARY.md` and `REPORT.md` are written, then a synthesis member writes `DELIVERABLE.md`
48
55
  and a read-only reviewer writes `POSTMORTEM.md`. The widget shows `synthesizing` until both
@@ -225,9 +232,12 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
225
232
  other member running, or no validation within `PI_HORDE_SOLVED_CONFIRM_MS`, an uncontested
226
233
  proposal commits as `solved-unvalidated`. Findings no other member reproduced hold wind-down
227
234
  while other members run, unless `PI_HORDE_ALLOW_UNVERIFIED=1`. A proposal pins the revision of
228
- every finding it cites. An answer that cites a superseded F# or quotes a provisional T# is
229
- refused at `solved` and at `confirm-solved`; a pending proposal whose cited finding is
230
- superseded or amended later is withdrawn (`solved-withdrawn`) for re-proposal. A contest that names idea ids reopens the proposal under a new revision
235
+ every finding and artifact it cites. An answer that cites a superseded F# or quotes a provisional
236
+ T# is refused at `solved` and at `confirm-solved`; a pending proposal whose cited finding is
237
+ superseded or amended later, or whose cited A# is republished, is withdrawn (`solved-withdrawn`)
238
+ for re-proposal, and ANSWER.md's status says so instead of keeping an earlier validation. At
239
+ finalize, ANSWER.md's status names every citation that changed after the proposal pinned it. A
240
+ contest that names idea ids reopens the proposal under a new revision
231
241
  (`solved-reopened`) once every named idea is done or ruled out, with the citations it pinned;
232
242
  if a cited finding changed or a quoted fact is still provisional by then, it is withdrawn instead. Repair serves a request about
233
243
  the pending answer (`target=answer`) with the blockers.
@@ -248,7 +258,10 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
248
258
  still `respond` to a deferred request, and the requester then resolves it. A challenger can
249
259
  withdraw its own blocking challenge on an F# or A# before any response by recording a
250
260
  supporting review of the current revision and resolving with evidence on it; limits that
251
- review discloses remain acceptance debt. Acceptance names an open blocker as `blocked by Q#`.
261
+ review discloses remain acceptance debt. A blocking request about the sender's own work (a
262
+ re-review after a correction) resolves on the recipient's supporting review of the current
263
+ revision, full-scope for an A#, since the owner may not review it. Acceptance names an open
264
+ blocker as `blocked by Q#`.
252
265
  - **Verifier.** The verifier's idea claims are refused while other members' findings await its
253
266
  review at their current revision; the pending F# list follows every review, reattest and board
254
267
  post. A verifier whose session closes with critical or high-importance findings unreviewed is
@@ -350,7 +363,9 @@ for an abnormal end after measurable work; distinct finding clusters with review
350
363
  superseded findings in their own section, and any resets; claim states; this run's facts split
351
364
  from carried context; resources and cleanup results; coverage (released and still-open ideas,
352
365
  the solved answer); late board posts; and consistency warnings for missing metadata,
353
- mixed-concern clusters, unknown R# citations, and cited paths that do not exist. A killed
366
+ mixed-concern clusters, unknown R# citations, and cited paths that do not exist (rooted URL paths
367
+ and templates with a date or placeholder slot, such as `logs/gate-YYYY-MM-DD.jsonl` or
368
+ `out/<id>.json`, are not checked). A killed
354
369
  member's summary is reconstructed from its last board post. The message injected into the parent
355
370
  carries a one-line diagnosis and, when generated, the post-mortem text.
356
371
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-horde",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Run a horde of pi agents on one task: shared ledgers, peer review, an auditable event log, and a live dashboard",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/citations.ts CHANGED
@@ -32,6 +32,12 @@ const DATA_EXT =
32
32
  /** Path roots that are worth checking even without a file extension. */
33
33
  const RUN_ROOT = /^(?:\.horde|\.tmp|runs|artifacts)(?:[/-]|$)/;
34
34
 
35
+ /**
36
+ * A path with a date or placeholder slot (`logs/gate-YYYY-MM-DD.jsonl`, `out/<id>.json`,
37
+ * `%Y%m%d.log`) names a pattern the code writes, not one file that must exist.
38
+ */
39
+ const PATH_TEMPLATE = /(?:^|[^A-Za-z])(?:YYYY|YYYYMMDD|yyyy|yyyymmdd)(?:[^A-Za-z]|$)|<[^<>]+>|%[YmdHMS]/;
40
+
35
41
  /** Host-anchored references such as docs.example.com/page are URLs, not local paths. */
36
42
  const HOST_PATH = /^(?:[a-z0-9-]+\.)+[a-z]{2,}\//i;
37
43
 
@@ -57,6 +63,7 @@ export function citedDataFiles(text: string): string[] {
57
63
  if (ref === "") return;
58
64
  if (ref.startsWith("/") || /^[a-z][a-z0-9+.-]*:\/\//i.test(ref)) return;
59
65
  if (/[{}*$]/.test(ref)) return;
66
+ if (PATH_TEMPLATE.test(ref)) return;
60
67
  if (HOST_PATH.test(ref)) return;
61
68
  const isRooted = RUN_ROOT.test(ref);
62
69
  // Under a run root, any extension is a member artifact worth checking. Off a
@@ -66,7 +73,9 @@ export function citedDataFiles(text: string): string[] {
66
73
  out.add(ref);
67
74
  };
68
75
  for (const match of text.matchAll(/`([^`\s]+)`/g)) consider(match[1] ?? "");
69
- for (const match of text.matchAll(/(?<![\w:/.])(?:\.{1,2}\/)?[\w.-]+(?:\/[\w.-]+)+(?:\.[A-Za-z0-9]{1,6})?/g)) {
76
+ // A match never starts inside a hyphenated word: `/standalone-client-gate/logs` is one URL path,
77
+ // not a relative `client-gate/logs` with its leading slash cut off.
78
+ for (const match of text.matchAll(/(?<![\w:/.-])(?:\.{1,2}\/)?[\w.-]+(?:\/[\w.-]+)+(?:\.[A-Za-z0-9]{1,6})?/g)) {
70
79
  consider(match[0] ?? "", text[(match.index ?? 0) + match[0].length] ?? "");
71
80
  }
72
81
  for (const match of text.matchAll(/(?<![\w/.-])([\w-]+\.[A-Za-z0-9]{1,6})\b/g)) consider(match[1] ?? "");
@@ -95,14 +104,16 @@ function fileNamedUnder(dir: string, name: string, depth = 6): boolean {
95
104
 
96
105
  /**
97
106
  * True when a cited file cannot be resolved to an existing file on disk. Paths
98
- * are tried against the repo root, the run directory, its artifacts directory,
99
- * and the directory of the citing file. Because generated artifacts usually cite
100
- * retained files by a relative name, and a deliverable cites the reviewed project's
107
+ * are tried against the repo root, the horde directory (where `runs/<id>/...`
108
+ * references resolve, in a single run and in a swarm horde alike), the run
109
+ * directory, its artifacts directory, and the directory of the citing file; a
110
+ * path without a file extension may name a directory. Because generated artifacts
111
+ * usually cite retained files by a relative name, and a deliverable cites the reviewed project's
101
112
  * own files by bare name, a basename match in this run's tree or in the project tree
102
113
  * also counts, which keeps the warning precise instead of noisy.
103
114
  */
104
115
  function citedFileMissing(run: HordeRun, ref: string, baseDir?: string): boolean {
105
- const roots = [run.cwd, run.runDir, path.join(run.runDir, "artifacts")];
116
+ const roots = [run.cwd, run.hordeDir, run.runDir, path.join(run.runDir, "artifacts")];
106
117
  if (baseDir) roots.push(baseDir);
107
118
  const mustBeFile = FILE_EXT.test(ref);
108
119
  for (const root of roots) {
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { collaborationState } from "./collaboration.js";
4
- import type { PeerMessage, ReviewRecord } from "./collaboration.js";
4
+ import type { ArtifactRecord, PeerMessage, ReviewRecord } from "./collaboration.js";
5
5
  import { formatClock } from "../format.js";
6
6
  import { RUN_HEADING } from "./board.js";
7
7
  import { registerConcern } from "./concerns.js";
@@ -558,28 +558,47 @@ export function supersededCitations(text: string, findings: FindingEntry[]): str
558
558
  .map((finding) => `F${finding.id} (now F${finding.supersededBy})`);
559
559
  }
560
560
 
561
- /** The current revision of every live finding a text cites, pinned so a later amend is visible. */
562
- export function answerCitations(findings: FindingEntry[], text: string): Record<string, string> {
561
+ /**
562
+ * The current revision of every live finding and published artifact a text cites, pinned so a
563
+ * later amend or republish is visible.
564
+ */
565
+ export function answerCitations(findings: FindingEntry[], text: string, artifacts: ArtifactRecord[] = []): Record<string, string> {
563
566
  const cites: Record<string, string> = {};
564
567
  for (const finding of findings) {
565
568
  if (finding.supersededBy === undefined && new RegExp(`\\bF${finding.id}\\b`).test(text)) cites[`F${finding.id}`] = findingRevision(finding);
566
569
  }
570
+ for (const artifact of artifacts) {
571
+ if (new RegExp(`\\b${artifact.id}\\b`).test(text)) cites[artifact.id] = artifact.hash;
572
+ }
567
573
  return cites;
568
574
  }
569
575
 
570
576
  /**
571
- * Citations an answer can no longer stand on: findings superseded since it was written, and
572
- * findings amended since the revisions it pinned. Both ship a stale reading as the result.
577
+ * Citations an answer can no longer stand on: findings superseded since it was written, findings
578
+ * amended and artifacts republished since the revisions it pinned. Each ships a stale reading as
579
+ * the result.
573
580
  */
574
- export function staleAnswerCitations(findings: FindingEntry[], text: string, cites: Record<string, string> = {}): string[] {
581
+ export function staleAnswerCitations(
582
+ findings: FindingEntry[],
583
+ text: string,
584
+ cites: Record<string, string> = {},
585
+ artifacts: ArtifactRecord[] = [],
586
+ ): string[] {
575
587
  const stale = supersededCitations(text, findings).map((citation) => `superseded ${citation}`);
576
588
  for (const [target, revision] of Object.entries(cites)) {
577
589
  const finding = findings.find((entry) => `F${entry.id}` === target);
578
590
  if (finding && finding.supersededBy === undefined && findingRevision(finding) !== revision) stale.push(`amended ${target}`);
591
+ const artifact = artifacts.find((entry) => entry.id === target);
592
+ if (artifact && artifact.hash !== revision) stale.push(`republished ${target}`);
579
593
  }
580
594
  return stale;
581
595
  }
582
596
 
597
+ /** The answer's stale citations against this run's current findings and artifacts. */
598
+ export function staleAnswerFor(hordeDir: string, runId: string, text: string, cites?: Record<string, string>): string[] {
599
+ return staleAnswerCitations(listFindingsForRun(hordeDir, runId), text, cites, collaborationState(hordeDir, runId).artifacts);
600
+ }
601
+
583
602
  /** A body line that reads as a verification note would move the subject boundary the revision hashes. */
584
603
  function neutralizeNotes(text: string): string {
585
604
  return text.replace(/^((?:Verified|Reviewed) by )/gm, "\\$1");
package/src/prompt.ts CHANGED
@@ -34,6 +34,8 @@ export interface RunSummaryInput {
34
34
  solvedCommitted?: boolean | undefined;
35
35
  /** Members whose queued repair work was skipped for budget. */
36
36
  repairSkipped?: string[] | undefined;
37
+ /** Members held back from later rounds because their attempt left every thread unchanged. */
38
+ repairStalled?: string[] | undefined;
37
39
  /** The REPORT.md outcome, so SUMMARY.md states the same end cause. */
38
40
  outcome?: string | undefined;
39
41
  /** One-line run diagnosis shown in the report message. */
@@ -126,6 +128,8 @@ Collaboration loop:
126
128
  a finding on that idea, explicitly set responseTarget=F#. Superseded F# requests follow the correction.
127
129
  Requester: attach/review evidence on the current response target, then resolve target=Q# refs=[E#]
128
130
  revision=current-hash text=decision, or defer with a reason. Disclosed finding limitations remain debt.
131
+ A blocking request about your own work resolves on the recipient's full supporting review of the
132
+ current revision, since you cannot review it yourself.
129
133
  Do not repeat your existing check: inspect collaboration first and test a specific remaining gap.
130
134
  A deferred blocking issue stays visible as acceptance debt; its recipient can still respond to it and
131
135
  the requester then resolves it. Never silently ignore a peer's failed test.
@@ -273,7 +277,8 @@ Protocol:
273
277
  proposal reopens for validation once every idea it named is done or ruled out. Pass the revision
274
278
  from ANSWER.md or 'horde inspect' target=answer to either action, so stale reviews cannot approve
275
279
  a newer proposal. An answer that cites a superseded F# or quotes a provisional T# is refused, and a
276
- pending proposal is withdrawn for re-proposal when a finding it cites is superseded. A request to
280
+ pending proposal is withdrawn for re-proposal when a finding it cites is superseded or amended,
281
+ or an artifact it cites is republished. A request to
277
282
  the proposer or a validator about the pending answer (target=answer) is served with the blockers.
278
283
  The parent enforces coverage before wind-down.
279
284
 
@@ -462,10 +467,12 @@ export function runEndSentence(run: RunSummaryInput): string {
462
467
  const attempts = run.members.reduce((n, m) => n + (m.repairs ?? 0), 0);
463
468
  const killed = run.members.filter((m) => ["tokens", "turns", "time"].includes(m.repairStopReason ?? ""));
464
469
  const skipped = run.repairSkipped ?? [];
470
+ const stalled = run.repairStalled ?? [];
465
471
  if (attempts > 0 || skipped.length > 0) {
466
472
  const detail = [
467
473
  killed.length ? `${killed.length} killed on budget (${killed.map((m) => `${m.name}: ${m.repairStopReason}`).join(", ")})` : "",
468
474
  skipped.length ? `${skipped.length} skipped for budget (${skipped.join(", ")})` : "",
475
+ stalled.length ? `${stalled.length} held after no progress (${stalled.join(", ")})` : "",
469
476
  ].filter(Boolean).join(", ");
470
477
  parts.push(`Repair: ${attempts} attempt(s)${detail ? `, ${detail}` : ""}.`);
471
478
  }
@@ -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, supersededCitations } from "../ledger/findings.js";
23
+ import { listFindingsForRun, parseFindings, staleAnswerFor } from "../ledger/findings.js";
24
24
  import type { FindingEntry } from "../ledger/findings.js";
25
25
  import { countIdeas, parseIdeas, reconcileOwnedIdeas } from "../ledger/ideas.js";
26
26
  import type { Idea } from "../ledger/ideas.js";
@@ -137,19 +137,18 @@ interface SettledAnswer {
137
137
  status: string;
138
138
  }
139
139
 
140
- /** Findings the proposal names that a later finding corrected; labeled, not re-versioned. */
141
140
  /**
142
141
  * The proposal body is the member's verbatim text and can predate later challenges and corrections.
143
- * The status names the blockers still open at freeze and the findings a later finding superseded,
144
- * and, once synthesis wrote it, points the operator at DELIVERABLE.md as the reconciled output.
142
+ * The status names the blockers still open at freeze and the citations that changed after the
143
+ * proposal pinned them (superseded or amended findings, republished artifacts), and, once
144
+ * synthesis wrote it, points the operator at DELIVERABLE.md as the reconciled output.
145
145
  */
146
- function annotateAnswer(run: HordeRun, answer: SettledAnswer, acceptance: { hash: string; issues: string[] }, findings: FindingEntry[]): void {
146
+ function annotateAnswer(run: HordeRun, answer: SettledAnswer, acceptance: { hash: string; issues: string[] }, stale: string[]): void {
147
147
  const blockers = acceptance.issues.filter((issue) => /^Q\d+ .*: \w+ blocking /.test(issue)).map((issue) => issue.replace(/:/, ""));
148
148
  const parts = [answer.status];
149
149
  if (acceptance.issues.length) parts.push("acceptance incomplete");
150
150
  if (blockers.length) parts.push(`open blocking at freeze: ${blockers.join(", ")}`);
151
- const superseded = supersededCitations(answer.proposal.text, findings);
152
- if (superseded.length) parts.push(`cites superseded ${superseded.join(", ")}`);
151
+ if (stale.length) parts.push(`cites ${stale.join(", ")}`);
153
152
  parts.push(`see ACCEPTANCE.md (snapshot ${acceptance.hash})`);
154
153
  if (run.deliverable) parts.push("reconciled operator output: DELIVERABLE.md; the proposal text below is verbatim and may be superseded there");
155
154
  try {
@@ -559,16 +558,16 @@ export async function finalizeRun(state: HordeState, swarm: Swarm, run: HordeRun
559
558
  run.acceptance = acceptance.status;
560
559
  run.snapshotHash = acceptance.hash;
561
560
  await recordLifecycle(run, "acceptance", { status: acceptance.status, hash: acceptance.hash, issues: acceptance.issues.length });
562
- const annotate = answer !== undefined &&
563
- (acceptance.issues.length > 0 || supersededCitations(answer.proposal.text, ledgers.findings).length > 0);
564
- if (annotate) annotateAnswer(run, answer, acceptance, ledgers.findings);
561
+ const stale = answer ? staleAnswerFor(run.hordeDir, run.id, answer.proposal.text, answer.proposal.cites) : [];
562
+ const annotate = answer !== undefined && (acceptance.issues.length > 0 || stale.length > 0);
563
+ if (annotate) annotateAnswer(run, answer, acceptance, stale);
565
564
  // Workers and bounded repairs have exited; only now may their labs be torn down.
566
565
  const cleanups = await runCleanups(run);
567
566
  const reportInput = writeRunReports(run, ledgers, releasedIdeas, cleanups, now);
568
567
  // Free the slot before the (possibly slow) synthesis so a new run can start.
569
568
  noteRunReported(state, swarm, run);
570
569
  await synthesize(state, run, ledgers, reportInput, now);
571
- if (annotate && run.deliverable) annotateAnswer(run, answer, acceptance, ledgers.findings);
570
+ if (annotate && run.deliverable) annotateAnswer(run, answer, acceptance, stale);
572
571
  sendReport(state, swarm, run, ledgers, now);
573
572
  await recordLifecycle(run, "run-end", { outcome: runOutcome(run), elapsedMs: Date.now() - run.startedAt, stopLabel: run.stopLabel });
574
573
  writeRunGraph(run);
package/src/run/repair.ts CHANGED
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { inboxFor } from "../tool/inbox.js";
5
5
  import type { PeerMessage } from "../ledger/collaboration.js";
6
6
  import { recordEvent } from "../ledger/events.js";
7
+ import type { EventContext } from "../ledger/events.js";
7
8
  import { recordPhase } from "./lifecycle.js";
8
9
  import { envNumber } from "../format.js";
9
10
  import { resetMemberState } from "../member.js";
@@ -24,11 +25,94 @@ function repairPriority(message: PeerMessage): number {
24
25
  return 3;
25
26
  }
26
27
 
27
- function queueFor(launch: RunLaunch, member: MemberState): PeerMessage[] {
28
- return inboxFor({ hordeDir: launch.run.hordeDir, runId: launch.run.id, actor: member.name })
28
+ /** The repair phase's scheduling state, shared by every round. */
29
+ interface RepairPhase {
30
+ launch: RunLaunch;
31
+ floors: RepairFloors;
32
+ /**
33
+ * Threads a member's completed attempts left exactly as they found them, by member then
34
+ * message id: the signature the thread was stuck at and how many consecutive attempts left it
35
+ * there. The thread is held back from that member once the count reaches `UNMOVED_ATTEMPTS`,
36
+ * until its signature changes, which only another member's action or a new response can cause.
37
+ */
38
+ stalled: Map<string, Map<string, UnmovedThread>>;
39
+ }
40
+
41
+ interface UnmovedThread {
42
+ signature: string;
43
+ attempts: number;
44
+ }
45
+
46
+ /**
47
+ * Completed attempts that may leave a thread untouched before it is held. One miss is routine: a
48
+ * member that answered the rest of its queue, or re-answered the task, often clears the thread on
49
+ * its next attempt within seconds. Two misses on the same signature are the loop this guards.
50
+ */
51
+ const UNMOVED_ATTEMPTS = 2;
52
+
53
+ function memberContext(phase: RepairPhase, member: MemberState): EventContext {
54
+ return { hordeDir: phase.launch.run.hordeDir, runId: phase.launch.run.id, actor: member.name };
55
+ }
56
+
57
+ /**
58
+ * What an attempt can change on a thread: its state and the revision it was answered with.
59
+ * Delivery receipts and a re-worded response on the same revision are not progress, so neither
60
+ * the inbox read of a fresh attempt nor a recipient repeating itself re-qualifies a thread.
61
+ */
62
+ function threadSignature(m: PeerMessage): string {
63
+ return `${m.state}:${m.responseRevision ?? ""}:${m.responseTarget ?? ""}`;
64
+ }
65
+
66
+ function isStalled(phase: RepairPhase, member: MemberState, m: PeerMessage): boolean {
67
+ const unmoved = phase.stalled.get(member.name)?.get(m.id);
68
+ return unmoved !== undefined && unmoved.signature === threadSignature(m) && unmoved.attempts >= UNMOVED_ATTEMPTS;
69
+ }
70
+
71
+ /** The threads currently held back from the member, in inbox order. */
72
+ function heldThreads(phase: RepairPhase, member: MemberState): string[] {
73
+ return inboxFor(memberContext(phase, member)).filter((m) => isStalled(phase, member, m)).map((m) => m.id);
74
+ }
75
+
76
+ /** The threads an attempt for the member would be handed: its inbox minus the threads held back. */
77
+ function queueFor(phase: RepairPhase, member: MemberState): PeerMessage[] {
78
+ return inboxFor(memberContext(phase, member)).filter((m) => !isStalled(phase, member, m))
29
79
  .sort((a, b) => repairPriority(a) - repairPriority(b) || a.createdAt.localeCompare(b.createdAt));
30
80
  }
31
81
 
82
+ /**
83
+ * Counts, per thread, the consecutive completed attempts that left it as they found it; a thread
84
+ * the attempt moved starts over. A killed attempt counts nothing: it may have been about to act.
85
+ * Returns the ids this attempt left unchanged.
86
+ */
87
+ function noteUnchanged(phase: RepairPhase, member: MemberState, queue: PeerMessage[]): string[] {
88
+ if (member.endCause !== "completed") return [];
89
+ const after = new Map(inboxFor(memberContext(phase, member)).map((m) => [m.id, threadSignature(m)]));
90
+ const unmoved = phase.stalled.get(member.name) ?? new Map<string, UnmovedThread>();
91
+ const unchanged: string[] = [];
92
+ for (const m of queue) {
93
+ const signature = threadSignature(m);
94
+ if (after.get(m.id) !== signature) { unmoved.delete(m.id); continue; }
95
+ const previous = unmoved.get(m.id);
96
+ unmoved.set(m.id, { signature, attempts: previous?.signature === signature ? previous.attempts + 1 : 1 });
97
+ unchanged.push(m.id);
98
+ }
99
+ phase.stalled.set(member.name, unmoved);
100
+ return unchanged;
101
+ }
102
+
103
+ /**
104
+ * Once every thread in the member's inbox is one its own completed attempt could not move, later
105
+ * rounds skip the member and its threads drop out of the demand that sizes the shares, until
106
+ * another member changes one. Recorded so the post-mortem sees why the member stopped appearing.
107
+ */
108
+ async function recordStalled(phase: RepairPhase, member: MemberState): Promise<void> {
109
+ const held = heldThreads(phase, member);
110
+ if (!held.length || queueFor(phase, member).length) return;
111
+ const { run } = phase.launch;
112
+ run.repairStalled = [...new Set([...(run.repairStalled ?? []), member.name])];
113
+ await recordEvent({ ...memberContext(phase, member), actor: "parent", attempt: "scheduler" }, "repair-skipped", { payload: { reason: "no-progress", members: [{ member: member.name, messages: held }] } });
114
+ }
115
+
32
116
  /**
33
117
  * What a queue costs in floors: one cold session covers the administrative threads in it, and
34
118
  * each review request or blocking challenge is an inspect/attach/review cycle of about a floor
@@ -114,10 +198,10 @@ export function repairBudget(demands: number[], floors: RepairFloors, rounds: nu
114
198
 
115
199
  type SkipReason = "insufficient-budget" | "insufficient-time";
116
200
 
117
- async function recordSkipped(launch: RunLaunch, pending: MemberState[], reason: SkipReason, detail: Record<string, number>): Promise<void> {
118
- const { run } = launch;
201
+ async function recordSkipped(phase: RepairPhase, pending: MemberState[], reason: SkipReason, detail: Record<string, number>): Promise<void> {
202
+ const { run } = phase.launch;
119
203
  const ctx = { hordeDir: run.hordeDir, runId: run.id, actor: "parent", attempt: "scheduler" };
120
- const members = pending.map((member) => ({ member: member.name, messages: queueFor(launch, member).map((m) => m.id) }))
204
+ const members = pending.map((member) => ({ member: member.name, messages: queueFor(phase, member).map((m) => m.id) }))
121
205
  .filter((entry) => entry.messages.length);
122
206
  if (!members.length) return;
123
207
  run.repairSkipped = [...new Set([...(run.repairSkipped ?? []), ...members.map((entry) => entry.member)])];
@@ -143,10 +227,11 @@ interface FirstTurnCost {
143
227
  * share is the allowance recorded and the deadline is derived from it once, so the record cannot
144
228
  * read a millisecond under the floor the share was raised to.
145
229
  */
146
- async function repairMember(launch: RunLaunch, member: MemberState, ms: number, limits: RepairLimits, floors: RepairFloors): Promise<void> {
230
+ async function repairMember(phase: RepairPhase, member: MemberState, ms: number, limits: RepairLimits): Promise<void> {
231
+ const { launch, floors } = phase;
147
232
  const { run } = launch;
148
- const ctx = { hordeDir: run.hordeDir, runId: run.id, actor: member.name };
149
- const queue = queueFor(launch, member);
233
+ const ctx = memberContext(phase, member);
234
+ const queue = queueFor(phase, member);
150
235
  if (!queue.length || stopReason(launch)) return;
151
236
  const prior = { turns: member.turns, toolCalls: member.toolCalls, usage: { ...member.usage }, startedAt: member.startedAt };
152
237
  resetMemberState(member);
@@ -181,7 +266,8 @@ async function repairMember(launch: RunLaunch, member: MemberState, ms: number,
181
266
  const timeout = setTimeout(() => halt("time"), Math.max(1, deadline - Date.now()));
182
267
  const watchdog = setInterval(check, 100);
183
268
  try {
184
- await recordEvent({ ...ctx, attempt: member.attempt }, "repair-start", { payload: { messages: queue.map((m) => m.id), demand: repairDemand(queue), prior, allowance, floors } });
269
+ const held = heldThreads(phase, member);
270
+ await recordEvent({ ...ctx, attempt: member.attempt }, "repair-start", { payload: { messages: queue.map((m) => m.id), demand: repairDemand(queue), prior, allowance, floors, ...(held.length ? { held } : {}) } });
185
271
  if (stopReason(launch) || Date.now() >= deadline) {
186
272
  halt(stopReason(launch) ?? "time");
187
273
  member.status = "stopped";
@@ -191,10 +277,12 @@ async function repairMember(launch: RunLaunch, member: MemberState, ms: number,
191
277
  // Enforce a share on every usage update, rather than waiting for the next poll.
192
278
  await spawnOne(launch, member, undefined, check);
193
279
  }
280
+ const unchanged = noteUnchanged(phase, member, queue);
194
281
  await recordEvent({ ...ctx, attempt: member.attempt }, "repair-end", { payload: {
195
282
  cause: member.endCause, stopReason: member.repairStopReason, allowance, usage: member.usage, turns: member.turns, firstTurn,
196
- messages: queue.map((m) => m.id),
283
+ messages: queue.map((m) => m.id), unchanged,
197
284
  } });
285
+ if (unchanged.length) await recordStalled(phase, member);
198
286
  } finally {
199
287
  clearTimeout(timeout);
200
288
  clearInterval(watchdog);
@@ -223,19 +311,42 @@ export async function drainRepairs(launch: RunLaunch): Promise<void> {
223
311
  }
224
312
  }
225
313
 
226
- function eligibleMembers(launch: RunLaunch): Array<{ member: MemberState; queue: PeerMessage[] }> {
227
- return launch.run.members.map((member) => ({ member, queue: queueFor(launch, member) })).filter((entry) => entry.queue.length)
314
+ function eligibleMembers(phase: RepairPhase): Array<{ member: MemberState; queue: PeerMessage[] }> {
315
+ return phase.launch.run.members.map((member) => ({ member, queue: queueFor(phase, member) })).filter((entry) => entry.queue.length)
228
316
  .sort((a, b) => repairPriority(a.queue[0]!) - repairPriority(b.queue[0]!) ||
229
317
  (a.member.repairs ?? 0) - (b.member.repairs ?? 0) || a.queue[0]!.createdAt.localeCompare(b.queue[0]!.createdAt));
230
318
  }
231
319
 
320
+ /** The queue a member was handed this round, by message id, at the signature it was handed. */
321
+ type HandedQueues = Map<string, Map<string, string>>;
322
+
323
+ /**
324
+ * True when every blocking thread left belongs to a member already served this round, and one of
325
+ * them is new or moved since that member was handed its queue. A correction answered mid-round
326
+ * leaves its requester to recheck it; finishing the round on administrative rechecks first spent
327
+ * the clock that recheck needed, so when a later round exists it starts now. A blocker the member
328
+ * left as it was handed is no reason to serve it again early; the last round serves everyone.
329
+ */
330
+ export function blockedOnServed(eligible: Array<{ member: MemberState; queue: PeerMessage[] }>, handed: HandedQueues): boolean {
331
+ let moved = false;
332
+ for (const { member, queue } of eligible) {
333
+ const seen = handed.get(member.name);
334
+ for (const m of queue) {
335
+ if (repairPriority(m) !== 0) continue;
336
+ if (!seen) return false;
337
+ if (seen.get(m.id) !== threadSignature(m)) moved = true;
338
+ }
339
+ }
340
+ return moved;
341
+ }
342
+
232
343
  async function runRepairRounds(launch: RunLaunch, begin: (budget: RepairBudget) => Promise<void>): Promise<void> {
233
344
  const { run } = launch;
234
345
  const rounds = Math.max(0, Math.min(6, envNumber("PI_HORDE_REPAIR_ROUNDS", 4)));
235
346
  if (rounds === 0 || stopReason(launch)) return;
236
- const floors = repairFloors();
347
+ const phase: RepairPhase = { launch, floors: repairFloors(), stalled: new Map() };
237
348
  await scheduleCollaboration(launch, { settleMs: 0, liveOnly: false });
238
- const budget = repairBudget(eligibleMembers(launch).map((entry) => repairDemand(entry.queue)), floors, rounds);
349
+ const budget = repairBudget(eligibleMembers(phase).map((entry) => repairDemand(entry.queue)), phase.floors, rounds);
239
350
  if (budget.queued === 0) return;
240
351
  const deadline = Date.now() + budget.ms;
241
352
  const exhausted = () => stopReason(launch) || Date.now() >= deadline || (run.repairTokens ?? 0) >= budget.tokens;
@@ -245,34 +356,35 @@ async function runRepairRounds(launch: RunLaunch, begin: (budget: RepairBudget)
245
356
  // One attempt per member per round, picked in priority order re-read before each attempt:
246
357
  // a thread that became blocking during an attempt moves its recipient ahead of the
247
358
  // administrative queues that were ordered first at round start.
248
- const served = new Set<string>();
359
+ const handed: HandedQueues = new Map();
249
360
  for (;;) {
250
361
  if (exhausted()) return;
251
- const pending = eligibleMembers(launch).filter((entry) => !served.has(entry.member.name));
252
- if (!pending.length) break;
362
+ const eligible = eligibleMembers(phase);
363
+ const pending = eligible.filter((entry) => !handed.has(entry.member.name));
364
+ if (!pending.length || (round < rounds - 1 && blockedOnServed(eligible, handed))) break;
253
365
  const next = pending[0]!;
254
366
  const pendingUnits = pending.reduce((sum, entry) => sum + repairDemand(entry.queue), 0);
255
367
  const units = repairDemand(next.queue);
256
368
  const unserved = pending.map((entry) => entry.member);
257
369
  const remaining = budget.tokens - (run.repairTokens ?? 0);
258
370
  const laterRounds = rounds - round - 1;
259
- const tokens = repairAllowance(repairShare(remaining, pendingUnits, laterRounds, units), remaining, floors.tokens);
371
+ const tokens = repairAllowance(repairShare(remaining, pendingUnits, laterRounds, units), remaining, phase.floors.tokens);
260
372
  if (tokens === undefined) {
261
373
  await begin(budget);
262
- await recordSkipped(launch, unserved, "insufficient-budget", { remaining, floor: floors.tokens });
374
+ await recordSkipped(phase, unserved, "insufficient-budget", { remaining, floor: phase.floors.tokens });
263
375
  return;
264
376
  }
265
377
  const remainingMs = Math.max(0, deadline - Date.now());
266
- const fundable = Math.floor(remaining / Math.max(1, floors.tokens));
267
- const ms = repairAllowance(repairTimeShare(remainingMs, pendingUnits, laterRounds, fundable, units), remainingMs, floors.ms);
378
+ const fundable = Math.floor(remaining / Math.max(1, phase.floors.tokens));
379
+ const ms = repairAllowance(repairTimeShare(remainingMs, pendingUnits, laterRounds, fundable, units), remainingMs, phase.floors.ms);
268
380
  if (ms === undefined || ms < 1) {
269
381
  await begin(budget);
270
- await recordSkipped(launch, unserved, "insufficient-time", { remainingMs, floorMs: floors.ms });
382
+ await recordSkipped(phase, unserved, "insufficient-time", { remainingMs, floorMs: phase.floors.ms });
271
383
  return;
272
384
  }
273
385
  await begin(budget);
274
- served.add(next.member.name);
275
- await repairMember(launch, next.member, ms, { tokens, turns: budget.turns }, floors);
386
+ handed.set(next.member.name, new Map(next.queue.map((m) => [m.id, threadSignature(m)])));
387
+ await repairMember(phase, next.member, ms, { tokens, turns: budget.turns });
276
388
  }
277
389
  }
278
390
  }
package/src/run/solved.ts CHANGED
@@ -4,7 +4,7 @@ import { loadCollaborationReport } from "../collaboration-report.js";
4
4
  import { openMessages } from "../ledger/collaboration.js";
5
5
  import { postBoard } from "../ledger/board.js";
6
6
  import { unsettledFactCitations } from "../ledger/facts.js";
7
- import { listFindingsForRun, staleAnswerCitations, unverifiedFindings } from "../ledger/findings.js";
7
+ import { staleAnswerFor, unverifiedFindings } from "../ledger/findings.js";
8
8
  import { listIdeas } from "../ledger/ideas.js";
9
9
  import { archiveSolved, clearCleanStop, clearSolved, readSolvedState, reopenSolved, resolveSolvedState, writeAnswer } from "../ledger/run-signals.js";
10
10
  import type { SolvedSignal } from "../ledger/run-signals.js";
@@ -80,14 +80,27 @@ function dropContested(run: HordeRun, proposal: SolvedSignal, contest: SolvedSig
80
80
  }
81
81
 
82
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.
83
+ * ANSWER.md keeps the withdrawn text for the record, but its status must stop naming the
84
+ * validation it held, or the run reads as validated after the proposal is gone.
85
+ */
86
+ function recordWithdrawn(run: HordeRun, proposal: SolvedSignal, reason: string): void {
87
+ void recordLifecycle(run, "solved-withdrawn", { member: proposal.member, reason });
88
+ try {
89
+ writeAnswer(run.runDir, run.id, proposal, `withdrawn: it ${reason}; no validation carries over and no proposal is pending`);
90
+ } catch (error) {
91
+ safeNotify(run, `horde ${run.id}: could not write ANSWER.md (${oneLine(String(error), 200)})`, "error");
92
+ }
93
+ }
94
+
95
+ /**
96
+ * A proposal that cites a finding superseded or amended, or an artifact republished, after it was
97
+ * made would ship the stale reading as the answer. It is withdrawn for re-proposal rather than
98
+ * validated; the board names the successor, the amendment, or the republish.
86
99
  */
87
100
  function withdrawStale(run: HordeRun, proposal: SolvedSignal, stale: string[]): void {
88
101
  const reason = `cites ${stale.join(", ")}`;
89
102
  safeNotify(run, `horde ${run.id}: solved proposal withdrawn; it ${reason}`, "warning");
90
- void recordLifecycle(run, "solved-withdrawn", { member: proposal.member, reason });
103
+ recordWithdrawn(run, proposal, reason);
91
104
  void postBoard(
92
105
  run.hordeDir,
93
106
  "parent",
@@ -111,13 +124,13 @@ function reopenContested(run: HordeRun): void {
111
124
  // The proposal returns with the citations it pinned when it was made; what went stale or
112
125
  // unsettled meanwhile makes it a re-proposal for its author, not a reopening.
113
126
  const stale = [
114
- ...staleAnswerCitations(listFindingsForRun(run.hordeDir, run.id), contest.proposal.text, contest.proposal.cites),
127
+ ...staleAnswerFor(run.hordeDir, run.id, contest.proposal.text, contest.proposal.cites),
115
128
  ...unsettledFactCitations(run.hordeDir, contest.proposal.text).map((citation) => `provisional ${citation}`),
116
129
  ];
117
130
  run.solvedContest = undefined;
118
131
  if (stale.length) {
119
132
  const why = `cites ${stale.join(", ")}`;
120
- void recordLifecycle(run, "solved-withdrawn", { member: contest.proposal.member, reason: why });
133
+ recordWithdrawn(run, contest.proposal, why);
121
134
  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
135
  return;
123
136
  }
@@ -262,7 +275,7 @@ export function checkSolved(run: HordeRun, ctx: ExtensionCommandContext): void {
262
275
  dropContested(run, solved, state.contest);
263
276
  return;
264
277
  }
265
- const stale = staleAnswerCitations(listFindingsForRun(run.hordeDir, run.id), solved.text, solved.cites);
278
+ const stale = staleAnswerFor(run.hordeDir, run.id, solved.text, solved.cites);
266
279
  if (stale.length && !run.solvedCommitted) {
267
280
  withdrawStale(run, solved, stale);
268
281
  return;
package/src/run/types.ts CHANGED
@@ -38,6 +38,8 @@ export interface HordeRun extends MemberRunInfo {
38
38
  repairTokens?: number;
39
39
  /** Members whose queued repair work was skipped because the budget could not fund an attempt. */
40
40
  repairSkipped?: string[];
41
+ /** Members whose remaining repair threads their own completed attempt could not move. */
42
+ repairStalled?: string[];
41
43
  /** True when a user command or session shutdown ended the run. */
42
44
  userStop?: boolean | undefined;
43
45
  /** Human-readable stop reason for the report, e.g. "stopped by user". */
@@ -7,7 +7,7 @@ import type { FactEntry } from "../ledger/facts.js";
7
7
  import * as path from "node:path";
8
8
  import { CLUSTER_THRESHOLD } from "../ledger/clusters.js";
9
9
  import { collaborationState } from "../ledger/collaboration.js";
10
- import { addFinding, amendFinding, answerCitations, findingReviewState, findingRevision, findingSummaries, listFindingsForRun, pendingReviewsForMember, staleAnswerCitations, verifyFinding } from "../ledger/findings.js";
10
+ import { addFinding, amendFinding, answerCitations, findingReviewState, findingRevision, findingSummaries, listFindingsForRun, pendingReviewsForMember, staleAnswerFor, verifyFinding } from "../ledger/findings.js";
11
11
  import { textOverlap } from "../ledger/text-match.js";
12
12
  import { addIdea, claimIdea, dropIdea, finishIdea, listIdeas, ruleOutIdea } from "../ledger/ideas.js";
13
13
  import type { Idea } from "../ledger/ideas.js";
@@ -466,13 +466,13 @@ function resourcesAction({ hordeDir, siblings }: ActionContext): ActionResult {
466
466
  }
467
467
 
468
468
  /**
469
- * An answer that cites a superseded finding, or one amended since the proposal pinned it, ships a
470
- * stale reading as the run's result. The proposer cites the current text instead; a validator is
471
- * refused until the proposer re-proposes.
469
+ * An answer that cites a superseded finding, or a finding amended or an artifact republished since
470
+ * the proposal pinned it, ships a stale reading as the run's result. The proposer cites the
471
+ * current text instead; a validator is refused until the proposer re-proposes.
472
472
  */
473
473
  function staleAnswer(ctx: ActionContext, text: string, cites?: Record<string, string>): string | undefined {
474
474
  const runId = requireRun(ctx);
475
- const stale = staleAnswerCitations(listFindingsForRun(ctx.hordeDir, runId), text, cites);
475
+ const stale = staleAnswerFor(ctx.hordeDir, runId, text, cites);
476
476
  if (!stale.length) return undefined;
477
477
  return `cites ${stale.join(", ")}`;
478
478
  }
@@ -489,7 +489,7 @@ async function solvedAction(ctx: ActionContext): Promise<ActionResult> {
489
489
  runId,
490
490
  ctx.member,
491
491
  ctx.params.text,
492
- answerCitations(listFindingsForRun(ctx.hordeDir, runId), text),
492
+ answerCitations(listFindingsForRun(ctx.hordeDir, runId), text, collaborationState(ctx.hordeDir, runId).artifacts),
493
493
  );
494
494
  return {
495
495
  text: alreadySolved
@@ -107,11 +107,12 @@ function responseSubject(c: CollaborationCommand, m: PeerMessage): string {
107
107
  }
108
108
 
109
109
  /**
110
- * A blocking request closes on the recipient's response plus the sender's own supporting check of
111
- * the current revision. Before any response the sender may still withdraw its own challenge on an
112
- * F# or A#: a challenger that verified the claim itself, or watched others verify it, records a
113
- * supporting review of the current revision and cites evidence on it. Limits that review
114
- * discloses stay acceptance debt on the review record; the withdrawal clears only the dispute.
110
+ * A blocking request closes on the recipient's response plus a supporting check of the current
111
+ * revision: the sender's own review on a challenge, or the recipient's review when the sender owns
112
+ * the target and so may not review it. Before any response the sender may still withdraw its own
113
+ * challenge on an F# or A#: a challenger that verified the claim itself, or watched others verify
114
+ * it, records a supporting review of the current revision and cites evidence on it. Limits that
115
+ * review discloses stay acceptance debt on the review record; the withdrawal clears only the dispute.
115
116
  */
116
117
  function validateResolution(c: CollaborationCommand, m: PeerMessage, refs: string[]): void {
117
118
  if (m.sender === m.to) throw new Error("horde: a blocking request needs an independent resolver");
@@ -121,13 +122,19 @@ function validateResolution(c: CollaborationCommand, m: PeerMessage, refs: strin
121
122
  if (refs.length === 0 || (withdrawal && !reviewable) || (!withdrawal && !["responded", "deferred"].includes(m.state))) {
122
123
  throw new Error("horde: blocking resolution needs a response and evidence");
123
124
  }
124
- const revision = targetInfo(c.ctx, c.state, target).revision;
125
+ const { owner, revision } = targetInfo(c.ctx, c.state, target);
125
126
  if ((c.params.revision ?? m.responseRevision ?? revision) !== revision) throw new Error(`horde: response is stale; explicitly review and resolve ${target}@${revision}, or ask the owner to respond again`);
126
127
  if (!refs.some((id) => c.state.evidence.some((e) => e.id === id && e.target === target && e.revision === revision))) {
127
128
  const detail = staleEvidence(c.state, refs, target, revision) ?? `none of ${refs.join(", ")} targets ${target}@${revision.slice(0, 12)}`;
128
129
  throw new Error(`horde: resolution evidence must address the current response target revision: ${detail}; attach evidence to that revision first`);
129
130
  }
130
131
  if (!reviewable) return;
132
+ if (owner === c.ctx.actor) requireRecipientReview(c, m, target, revision, withdrawal);
133
+ else requireOwnReview(c, target, revision, withdrawal);
134
+ }
135
+
136
+ /** A challenger settles its own dispute: its latest review of the current revision must support. */
137
+ function requireOwnReview(c: CollaborationCommand, target: string, revision: string, withdrawal: boolean): void {
131
138
  const review = c.state.reviews.findLast((r) => r.target === target && r.revision === revision && r.actor === c.ctx.actor);
132
139
  if (!review || review.verdict !== "supports") {
133
140
  throw new Error(withdrawal
@@ -137,6 +144,24 @@ function validateResolution(c: CollaborationCommand, m: PeerMessage, refs: strin
137
144
  if (!withdrawal && target.startsWith("A") && review.unchecked) throw new Error("horde: resolve requires your supporting review of the current revision; finding limitations remain acceptance debt");
138
145
  }
139
146
 
147
+ /**
148
+ * The owner of the target cannot review it, so a request it sent about its own work (a re-review
149
+ * after a correction) resolves on the recipient's supporting review of the current revision. The
150
+ * refusal names what is missing, since telling the owner to review its own artifact leads nowhere.
151
+ */
152
+ function requireRecipientReview(c: CollaborationCommand, m: PeerMessage, target: string, revision: string, withdrawal: boolean): void {
153
+ const review = c.state.reviews.findLast((r) => r.target === target && r.revision === revision && r.actor === m.to);
154
+ const short = `${target}@${revision.slice(0, 12)}`;
155
+ const prefix = `horde: ${target} is yours, so ${m.id} resolves on ${m.to}'s review`;
156
+ if (!review || review.verdict !== "supports") {
157
+ const missing = review ? `${review.id} is ${review.verdict}, not supports` : `no supporting review of ${short} by ${m.to} yet`;
158
+ throw new Error(`${prefix}: ${missing}; ask ${m.to} to review the current revision`);
159
+ }
160
+ if (!withdrawal && target.startsWith("A") && review.unchecked) {
161
+ throw new Error(`${prefix}: ${review.id} left unchecked: ${review.unchecked}; ask ${m.to} for a full review of ${short}`);
162
+ }
163
+ }
164
+
140
165
  function attachEvidence(c: CollaborationCommand): EventDraft {
141
166
  const { ctx, state, params: p, seq } = c;
142
167
  const target = requireParam(p.target, "target", p.action);