agent-horde 0.1.2 → 0.1.4

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/src/member.ts CHANGED
@@ -26,7 +26,7 @@ export interface MemberUsage {
26
26
  export interface MemberState {
27
27
  attempt?: string;
28
28
  repairs?: number;
29
- repairStopReason?: "tokens" | "time" | "user-stop" | "disposed" | "master-abort";
29
+ repairStopReason?: "tokens" | "turns" | "time" | "user-stop" | "disposed" | "master-abort";
30
30
  name: string;
31
31
  index: number;
32
32
  status: MemberStatus;
package/src/prompt.ts CHANGED
@@ -11,6 +11,9 @@ import type { ResourceEntry } from "./ledger/resources.js";
11
11
  import type { SolvedSignal } from "./ledger/run-signals.js";
12
12
  import type { MemberReset, MemberState } from "./member.js";
13
13
 
14
+ /** A resource with the non-owner members whose findings, evidence, reviews or leases cite it. */
15
+ export type ReportResource = ResourceEntry & { citedBy?: string[] | undefined };
16
+
14
17
  export interface RunSummaryInput {
15
18
  id: string;
16
19
  prompt: string;
@@ -23,8 +26,16 @@ export interface RunSummaryInput {
23
26
  userStop?: boolean | undefined;
24
27
  /** Human-readable stop reason, e.g. "stopped by user" or "stopped at deadline". */
25
28
  stopLabel?: string | undefined;
26
- /** Set when a member reported the goal solved; the stop cause must agree with REPORT.md. */
29
+ /** Set when a member reported the goal solved; the end sentence states whether the run wound down on it. */
27
30
  solved?: SolvedSignal | undefined;
31
+ /** The other member that validated the proposal, if any. */
32
+ solvedValidatedBy?: string | undefined;
33
+ /** True when the parent wound the run down on the validated proposal. */
34
+ solvedCommitted?: boolean | undefined;
35
+ /** Members whose queued repair work was skipped for budget. */
36
+ repairSkipped?: string[] | undefined;
37
+ /** The REPORT.md outcome, so SUMMARY.md states the same end cause. */
38
+ outcome?: string | undefined;
28
39
  /** One-line run diagnosis shown in the report message. */
29
40
  diagnosis?: string | undefined;
30
41
  /** Post-mortem markdown appended to the report when one was generated. */
@@ -87,14 +98,16 @@ Collaboration loop:
87
98
  - Attach evidence to anyone's work: horde attach target=F# text=observation relation=supports|contradicts|extends,
88
99
  optionally artifactPath=artifacts/file. Returned E# retains the full evidence. Ownership is unnecessary.
89
100
  - horde message target=F#/A#/I#/R# kind=challenge|review-request|question|blocker|handoff|decision text=question,
90
- to=member-N (default owner), refs=[E#]. Challenges/blockers default to blocking acceptance.
91
- - Read horde inbox. Recipient: ack target=Q# text=plan, then respond target=Q# revision=hash text=result.
101
+ to=member-N (default owner; a request about your own work names another member), refs=[E#].
102
+ Challenges/blockers default to blocking acceptance.
103
+ - Read horde inbox. Recipient: respond target=Q# revision=hash text=result; ack target=Q# text=plan first only to signal intent.
92
104
  Respond with the challenged target's current revision, not another finding's hash. For an I# resolved by
93
105
  a finding on that idea, explicitly set responseTarget=F#. Superseded F# requests follow the correction.
94
106
  Requester: attach/review evidence on the current response target, then resolve target=Q# refs=[E#]
95
107
  revision=current-hash text=decision, or defer with a reason. Disclosed finding limitations remain debt.
96
108
  Do not repeat your existing check: inspect collaboration first and test a specific remaining gap.
97
- A deferred blocking issue stays visible as acceptance debt. Never silently ignore a peer's failed test.
109
+ A deferred blocking issue stays visible as acceptance debt; its recipient can still respond to it and
110
+ the requester then resolves it. Never silently ignore a peer's failed test.
98
111
  - Publish runnable outputs with horde artifact artifactKind=runnable artifactPath=artifacts/file
99
112
  criteria=positive-and-negative-tests dependsOn=[F#]. Assessment documents use artifactKind=document
100
113
  and criteria covering citation accuracy, claim scope and disclosed limits. Full independent sourced/derived
@@ -351,14 +364,38 @@ function memberSummarySection(
351
364
  return lines;
352
365
  }
353
366
 
367
+ /**
368
+ * How the run ended, from what the parent recorded rather than from the solved flag alone: a
369
+ * proposal can be validated and still not wind the run down when coverage or review debt holds
370
+ * it, and the repair phase can end the run on budget instead.
371
+ */
372
+ export function runEndSentence(run: RunSummaryInput): string {
373
+ const parts: string[] = [];
374
+ if (run.solved) {
375
+ const at = run.solved.at ? ` at ${formatClock(new Date(run.solved.at))}` : "";
376
+ const validated = run.solvedValidatedBy ? `validated by ${run.solvedValidatedBy}` : "not validated by another member";
377
+ const wound = run.solvedCommitted ? "the run wound down on it" : "the run did not wind down on it";
378
+ parts.push(`${run.solved.member} proposed the goal solved${at}, ${validated}; ${wound}.`);
379
+ }
380
+ parts.push(run.stopped ? `Run ${run.stopLabel ?? "was stopped by the user"}.` : "Run ended when every member exited.");
381
+ const attempts = run.members.reduce((n, m) => n + (m.repairs ?? 0), 0);
382
+ const killed = run.members.filter((m) => ["tokens", "turns", "time"].includes(m.repairStopReason ?? ""));
383
+ const skipped = run.repairSkipped ?? [];
384
+ if (attempts > 0 || skipped.length > 0) {
385
+ const detail = [
386
+ killed.length ? `${killed.length} killed on budget (${killed.map((m) => `${m.name}: ${m.repairStopReason}`).join(", ")})` : "",
387
+ skipped.length ? `${skipped.length} skipped for budget (${skipped.join(", ")})` : "",
388
+ ].filter(Boolean).join(", ");
389
+ parts.push(`Repair: ${attempts} attempt(s)${detail ? `, ${detail}` : ""}.`);
390
+ }
391
+ if (run.outcome) parts.push(`Outcome: ${run.outcome}.`);
392
+ return parts.join(" ");
393
+ }
394
+
354
395
  export function summaryMarkdown(run: RunSummaryInput, now: number = Date.now()): string {
355
396
  const counts = statusCounts(run.members);
356
397
  const usage = totalUsage(run.members);
357
- const end = run.solved
358
- ? ` Run ended after ${run.solved.member} reported the goal solved.`
359
- : run.stopped
360
- ? ` Run was stopped (${run.stopLabel ?? "by the user"}).`
361
- : "";
398
+ const end = ` ${runEndSentence(run)}`;
362
399
  const lines = [
363
400
  `# Horde run ${run.id}`,
364
401
  "",
@@ -492,14 +529,16 @@ export interface RunReportInput extends RunSummaryInput {
492
529
  facts: FactEntry[];
493
530
  /** Merged deliverable markdown when one was generated; REPORT.md points at it. */
494
531
  deliverable?: string | undefined;
495
- /** Shared resources claimed during the run. */
496
- resources?: ResourceEntry[] | undefined;
532
+ /** Shared resources claimed during the run, with the non-owners whose records cite each one. */
533
+ resources?: ReportResource[] | undefined;
534
+ /** Members credited with validating the proposal, and those who confirmed it after that. */
535
+ solvedConfirmations?: { credited: string[]; additional: string[] } | undefined;
536
+ /** Idea creations this run made, and the ones dedup folded into an existing idea. */
537
+ ideaCreations?: { created: number; absorbed: Array<{ id: number; count: number }> } | undefined;
497
538
  /** Set when a member reported the goal answered. */
498
539
  solved?: SolvedSignal | undefined;
499
540
  /** True when a SOLVED proposal was contested and no validated answer replaced it. */
500
541
  solvedContested?: boolean | undefined;
501
- /** The other member that validated the solved proposal, if any. */
502
- solvedValidatedBy?: string | undefined;
503
542
  /** Resource teardown results from finalize. */
504
543
  cleanups?: Array<{ name: string; command: string; status: CleanupStatus; detail: string }> | undefined;
505
544
  /** Member board posts that landed after the last finding. */
@@ -550,8 +589,10 @@ function artifactDiagnosis(input: RunReportInput): string[] {
550
589
  lines.push("- deliverable: DELIVERABLE.md (merged task deliverable in this run directory)");
551
590
  }
552
591
  if (input.resources && input.resources.length > 0) {
553
- const reused = input.resources.reduce((n, resource) => n + resource.reusers.length, 0);
554
- lines.push(`- resources: ${input.resources.length} claimed, ${reused} reuse(s)`);
592
+ const reclaims = input.resources.reduce((n, resource) => n + resource.reusers.length, 0);
593
+ const citing = new Set(input.resources.flatMap((resource) => resource.citedBy ?? []));
594
+ const reused = input.resources.filter((resource) => resource.reusers.length > 0 || (resource.citedBy ?? []).length > 0).length;
595
+ lines.push(`- resources: ${input.resources.length} claimed, ${reused} reused (${reclaims} explicit reclaim(s), ${citing.size} citing member(s))`);
555
596
  }
556
597
  if (input.solved) {
557
598
  const how = input.solvedValidatedBy
@@ -562,6 +603,11 @@ function artifactDiagnosis(input: RunReportInput): string[] {
562
603
  lines.push(
563
604
  `- solved: ${input.solved.member} proposed the goal answered (${how}): ${oneLine(input.solved.text, 200)}`,
564
605
  );
606
+ if (input.solvedConfirmations) {
607
+ const { credited, additional } = input.solvedConfirmations;
608
+ const also = additional.length ? `; also confirmed by ${additional.join(", ")}` : "";
609
+ lines.push(`- confirm-solved: ${credited.length ? `credited ${credited.join(", ")}` : "none credited"}${also}`);
610
+ }
565
611
  lines.push("- answer: ANSWER.md (full answer text in this run directory)");
566
612
  } else if (input.solvedContested) {
567
613
  lines.push("- solved: contested; no validated answer survived the run");
@@ -663,6 +709,12 @@ function ledgerDiagnosis(input: RunReportInput, supersededBy: Map<number, number
663
709
  .join(", ")}`,
664
710
  );
665
711
  }
712
+ if (input.ideaCreations) {
713
+ const { created, absorbed } = input.ideaCreations;
714
+ const total = absorbed.reduce((n, entry) => n + entry.count, 0);
715
+ const detail = absorbed.length ? ` (${absorbed.map((entry) => `#${entry.id} x${entry.count}`).join(", ")})` : "";
716
+ lines.push(`- ideas: ${created} created, ${total} duplicate creation(s) absorbed${detail}`);
717
+ }
666
718
  return lines;
667
719
  }
668
720
 
@@ -719,10 +771,13 @@ function findingLine(finding: FindingEntry, input: RunReportInput): string {
719
771
  );
720
772
  }
721
773
 
774
+ /** A finding's title is its first line; a single-paragraph finding would otherwise print its whole body as the headline. */
775
+ const HEADLINE_CHARS = 160;
776
+
722
777
  function findingsSection(input: RunReportInput, supersededBy: Map<number, number>): string[] {
723
778
  const reviewRank = (cluster: FindingCluster): number => Math.max(...cluster.findings.map((f) => ({ full: 3, limited: 2, legacy: 1, disputed: 0, unreviewed: 0, superseded: 0 })[findingReviewState(f, input.scopedReviews, input.reviewMessages).status]));
724
779
  const liveClusters = input.clusters.filter((cluster) => !supersededBy.has(cluster.representative.id)).sort((a, b) => reviewRank(b) - reviewRank(a));
725
- const supersededClusters = input.clusters.filter((cluster) => supersededBy.has(cluster.representative.id));
780
+ const superseded = input.findings.filter((finding) => supersededBy.has(finding.id));
726
781
  const lines = [
727
782
  "",
728
783
  "## Distinct findings (ranked by review status, then legacy/support ranking, then importance, " +
@@ -734,16 +789,15 @@ function findingsSection(input: RunReportInput, supersededBy: Map<number, number
734
789
  const delta =
735
790
  cluster.deltaMembers.length > 0 ? `; deltas: ${cluster.deltaMembers.join(", ")}` : "";
736
791
  lines.push(
737
- `${index + 1}. ${cluster.representative.title} ` +
792
+ `${index + 1}. ${oneLine(cluster.representative.title, HEADLINE_CHARS)} ` +
738
793
  `(members: ${cluster.members.join(", ") || "none"}${delta}; ${cluster.findings.length} entries)`,
739
794
  );
740
795
  for (const finding of cluster.findings) lines.push(findingLine(finding, input));
741
796
  });
742
- if (supersededClusters.length > 0) {
797
+ if (superseded.length > 0) {
743
798
  lines.push("", "## Superseded by later findings", "");
744
- for (const cluster of supersededClusters) {
745
- const by = supersededBy.get(cluster.representative.id);
746
- lines.push(`- F${cluster.representative.id} (superseded by F${by}): ${cluster.representative.title}`);
799
+ for (const finding of superseded) {
800
+ lines.push(`- F${finding.id} (superseded by F${supersededBy.get(finding.id)}): ${oneLine(finding.title, HEADLINE_CHARS)}`);
747
801
  }
748
802
  }
749
803
  return lines;
@@ -771,10 +825,14 @@ function runArtifactSections(input: RunReportInput): string[] {
771
825
  if (input.resources && input.resources.length > 0) {
772
826
  lines.push("", "## Resources", "");
773
827
  for (const resource of input.resources) {
828
+ const reuse = [
829
+ resource.reusers.length > 0 ? `reused by ${resource.reusers.join(", ")}` : "",
830
+ (resource.citedBy ?? []).length > 0 ? `cited by ${(resource.citedBy ?? []).join(", ")}` : "",
831
+ ].filter(Boolean).join("; ");
774
832
  lines.push(
775
833
  `- R${resource.id} ${resource.name} @${resource.owner}` +
776
834
  `${resource.note ? `: ${resource.note}` : ""}` +
777
- `${resource.reusers.length > 0 ? ` (reused by ${resource.reusers.join(", ")})` : ""}`,
835
+ `${reuse ? ` (${reuse})` : ""}`,
778
836
  );
779
837
  }
780
838
  }
@@ -5,6 +5,7 @@ import {
5
5
  ledgerCitationWarnings,
6
6
  reportConsistencyWarnings,
7
7
  resourceCitations,
8
+ resourceCitingMembers,
8
9
  } from "../citations.js";
9
10
  import { errorMessage, oneLine } from "../format.js";
10
11
  import { freezeAcceptance } from "../collaboration-report.js";
@@ -13,6 +14,8 @@ import { listClaims, markClaimsUnverified } from "../ledger/claims.js";
13
14
  import type { ClaimEntry } from "../ledger/claims.js";
14
15
  import { collaborationState } from "../ledger/collaboration.js";
15
16
  import { readEvents } from "../ledger/events.js";
17
+ import type { HordeEvent } from "../ledger/events.js";
18
+ import { absorbedIdeaCreations, ideaCreationCount, solvedConfirmations } from "../ledger/event-metrics.js";
16
19
  import { clusterFindings } from "../ledger/clusters.js";
17
20
  import { listFacts } from "../ledger/facts.js";
18
21
  import type { FactEntry } from "../ledger/facts.js";
@@ -22,14 +25,14 @@ import type { FindingEntry } from "../ledger/findings.js";
22
25
  import { countIdeas, parseIdeas, reconcileOwnedIdeas } from "../ledger/ideas.js";
23
26
  import type { Idea } from "../ledger/ideas.js";
24
27
  import { listResources, pendingCleanups, readRunResources } from "../ledger/resources.js";
25
- import type { PendingCleanup, ResourceEntry } from "../ledger/resources.js";
28
+ import type { PendingCleanup } from "../ledger/resources.js";
26
29
  import { archiveSolved, clearCleanStop, clearSolved, readSolvedState, resolveSolvedState, writeAnswer } from "../ledger/run-signals.js";
27
30
  import type { SolvedSignal } from "../ledger/run-signals.js";
28
31
  import { childEnv, execPi } from "../member.js";
29
32
  import { reportText, runReportMarkdown, summaryMarkdown } from "../prompt.js";
30
33
  import { buildRunGraph } from "../run-graph.js";
31
34
  import { runGraphMarkdown } from "../run-graph-mermaid.js";
32
- import type { CleanupStatus, RunReportInput } from "../prompt.js";
35
+ import type { CleanupStatus, ReportResource, RunReportInput } from "../prompt.js";
33
36
  import { buildDiagnosis } from "./diagnosis.js";
34
37
  import { recordLifecycle, recordPhase } from "./lifecycle.js";
35
38
  import { runDeliverable, runPostmortem } from "./synthesis.js";
@@ -46,8 +49,8 @@ interface RunLedgers {
46
49
  findings: FindingEntry[];
47
50
  claims: ClaimEntry[];
48
51
  facts: FactEntry[];
49
- /** Resources this run claimed or reused. */
50
- resources: ResourceEntry[];
52
+ /** Resources this run claimed or reused, with the non-owners whose records cite each one. */
53
+ resources: ReportResource[];
51
54
  }
52
55
 
53
56
  type CleanupResult = NonNullable<RunReportInput["cleanups"]>[number];
@@ -128,15 +131,25 @@ interface SettledAnswer {
128
131
  status: string;
129
132
  }
130
133
 
134
+ /** Findings the proposal names that a later finding corrected; labeled, not re-versioned. */
135
+ export function supersededCitations(text: string, findings: FindingEntry[]): string[] {
136
+ return findings
137
+ .filter((finding) => finding.supersededBy !== undefined && new RegExp(`\\bF${finding.id}\\b`).test(text))
138
+ .map((finding) => `F${finding.id} (now F${finding.supersededBy})`);
139
+ }
140
+
131
141
  /**
132
- * The proposal body is the member's verbatim text and can predate later challenges. When acceptance
133
- * fails, the status names the blockers still open at freeze and, once synthesis wrote it, points
134
- * the operator at DELIVERABLE.md as the reconciled output.
142
+ * 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.
135
145
  */
136
- function annotateAnswer(run: HordeRun, answer: SettledAnswer, acceptance: { hash: string; issues: string[] }): void {
146
+ function annotateAnswer(run: HordeRun, answer: SettledAnswer, acceptance: { hash: string; issues: string[] }, findings: FindingEntry[]): void {
137
147
  const blockers = acceptance.issues.filter((issue) => /^Q\d+ .*: \w+ blocking /.test(issue)).map((issue) => issue.replace(/:/, ""));
138
- const parts = [answer.status, "acceptance incomplete"];
148
+ const parts = [answer.status];
149
+ if (acceptance.issues.length) parts.push("acceptance incomplete");
139
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(", ")}`);
140
153
  parts.push(`see ACCEPTANCE.md (snapshot ${acceptance.hash})`);
141
154
  if (run.deliverable) parts.push("reconciled operator output: DELIVERABLE.md; the proposal text below is verbatim and may be superseded there");
142
155
  try {
@@ -212,7 +225,9 @@ function readRunLedgers(run: HordeRun): RunLedgers {
212
225
  ledgers.claims = listClaims(run.hordeDir);
213
226
  ledgers.facts = listFacts(run.hordeDir);
214
227
  const touched = new Set(readRunResources(run.runDir));
215
- ledgers.resources = listResources(run.hordeDir).filter((resource) => touched.has(resource.id));
228
+ const resources = listResources(run.hordeDir).filter((resource) => touched.has(resource.id));
229
+ const citing = resourceCitingMembers(resources, ledgers.findings, collaborationState(run.hordeDir, run.id));
230
+ ledgers.resources = resources.map((resource) => ({ ...resource, citedBy: citing.get(resource.id) ?? [] }));
216
231
  } catch {
217
232
  // FINDINGS/CLAIMS/FACTS.md was removed mid-run.
218
233
  }
@@ -322,14 +337,23 @@ function writeRunReports(
322
337
  latePosts,
323
338
  });
324
339
  const memberPosts = lastBoardPostsByMember(run.hordeDir, run.id);
340
+ const outcome = runOutcome(run);
341
+ let events: HordeEvent[] = [];
342
+ try {
343
+ events = readEvents(run.hordeDir, run.id);
344
+ } catch (error) {
345
+ safeNotify(run, `horde ${run.id}: could not read events for the report (${errorMessage(error)})`, "error");
346
+ }
325
347
  writeRunFile(
326
348
  run,
327
349
  "SUMMARY.md",
328
- summaryMarkdown({ ...run, findings, ideas: ledgers.ideas, memberPosts, scopedReviews: reviewState.reviews, reviewMessages: reviewState.messages }, now),
350
+ summaryMarkdown({ ...run, outcome, findings, ideas: ledgers.ideas, memberPosts, scopedReviews: reviewState.reviews, reviewMessages: reviewState.messages }, now),
329
351
  );
330
352
  const reportInput: RunReport = {
331
353
  ...run,
332
- outcome: runOutcome(run),
354
+ outcome,
355
+ solvedConfirmations: solvedConfirmations(events),
356
+ ideaCreations: { created: ideaCreationCount(events), absorbed: absorbedIdeaCreations(events) },
333
357
  scopedReviews: reviewState.reviews,
334
358
  reviewMessages: reviewState.messages,
335
359
  canary: run.canary,
@@ -525,15 +549,16 @@ export async function finalizeRun(state: HordeState, run: HordeRun): Promise<voi
525
549
  run.acceptance = acceptance.status;
526
550
  run.snapshotHash = acceptance.hash;
527
551
  await recordLifecycle(run, "acceptance", { status: acceptance.status, hash: acceptance.hash, issues: acceptance.issues.length });
528
- const annotate = answer !== undefined && acceptance.issues.length > 0;
529
- if (annotate) annotateAnswer(run, answer, acceptance);
552
+ const annotate = answer !== undefined &&
553
+ (acceptance.issues.length > 0 || supersededCitations(answer.proposal.text, ledgers.findings).length > 0);
554
+ if (annotate) annotateAnswer(run, answer, acceptance, ledgers.findings);
530
555
  // Workers and bounded repairs have exited; only now may their labs be torn down.
531
556
  const cleanups = await runCleanups(state, run);
532
557
  const reportInput = writeRunReports(run, ledgers, releasedIdeas, cleanups, now);
533
558
  // Free the slot before the (possibly slow) synthesis so a new run can start.
534
559
  if (state.active === run) state.active = undefined;
535
560
  await synthesize(state, run, ledgers, reportInput, now);
536
- if (annotate && run.deliverable) annotateAnswer(run, answer, acceptance);
561
+ if (annotate && run.deliverable) annotateAnswer(run, answer, acceptance, ledgers.findings);
537
562
  sendReport(state, run, ledgers, now);
538
563
  await recordLifecycle(run, "run-end", { outcome: runOutcome(run), elapsedMs: Date.now() - run.startedAt, stopLabel: run.stopLabel });
539
564
  writeRunGraph(run);
package/src/run/repair.ts CHANGED
@@ -26,31 +26,117 @@ function queueFor(launch: RunLaunch, member: MemberState): PeerMessage[] {
26
26
  .sort((a, b) => repairPriority(a) - repairPriority(b) || a.createdAt.localeCompare(b.createdAt));
27
27
  }
28
28
 
29
- /** Reserve shares for the other queued members and up to two correction/recheck turns. */
30
- export function repairShare(remaining: number, queued: number, laterRounds: number): number {
31
- return Math.max(0, Math.floor(remaining / (Math.max(1, queued) + Math.min(2, Math.max(0, laterRounds)))));
29
+ /**
30
+ * What a queue costs in floors: one cold session covers the administrative threads in it, and
31
+ * each review request or blocking challenge is an inspect/attach/review cycle of about a floor
32
+ * on its own. A seven-handoff queue and a one-review queue both demand one unit; a queue holding
33
+ * three reviews demands three, so its share is sized to the work rather than to the member.
34
+ */
35
+ export function repairDemand(queue: PeerMessage[]): number {
36
+ const checks = queue.filter((m) => m.blocking || (m.kind === "review-request" && /^[AF]\d+$/.test(m.target))).length;
37
+ return Math.max(1, checks);
38
+ }
39
+
40
+ /** The reservation a share is cut from: every pending unit plus up to two correction/recheck turns. */
41
+ function reservedUnits(pendingUnits: number, laterRounds: number): number {
42
+ return Math.max(1, pendingUnits) + Math.min(2, Math.max(0, laterRounds));
43
+ }
44
+
45
+ /** This attempt's share of what remains, in proportion to its demand against every pending unit. */
46
+ export function repairShare(remaining: number, pendingUnits: number, laterRounds: number, units = 1): number {
47
+ return Math.max(0, Math.floor((remaining * units) / reservedUnits(pendingUnits, laterRounds)));
32
48
  }
33
49
 
34
50
  /**
35
- * A fresh repair session pays the full system prompt, tool schemas and task on its first turn
36
- * (about 8k uncached tokens on deepseek). A share below that is killed before it can act, so each
37
- * attempt gets at least the floor and lower-priority members go unserved instead.
51
+ * The clock is split only among the attempts the token budget can still pay for. Reserving time
52
+ * for members the token floor will skip anyway shrank every attempt to a share nobody could use.
38
53
  */
39
- function repairAllowance(share: number, remaining: number, floor: number): number | undefined {
54
+ export function repairTimeShare(remainingMs: number, pendingUnits: number, laterRounds: number, fundable: number, units = 1): number {
55
+ const divisor = Math.min(reservedUnits(pendingUnits, laterRounds), Math.max(1, fundable));
56
+ return Math.max(0, Math.min(remainingMs, Math.floor((remainingMs * units) / divisor)));
57
+ }
58
+
59
+ /**
60
+ * A fresh repair session pays the full system prompt, tool schemas, task and queued inbox before
61
+ * it can act: about 20k input+output tokens over five turns on deepseek for a queue of two to
62
+ * four threads, and the better part of a minute to read, inspect, attach and review. A share
63
+ * below either floor is killed before it acts, so each attempt gets at least the floor and
64
+ * lower-priority members go unserved instead.
65
+ */
66
+ export function repairAllowance(share: number, remaining: number, floor: number): number | undefined {
40
67
  if (remaining < Math.max(1, floor)) return undefined;
41
68
  return Math.max(share, floor);
42
69
  }
43
70
 
44
- async function recordSkipped(launch: RunLaunch, pending: MemberState[], detail: Record<string, number>): Promise<void> {
71
+ export interface RepairFloors {
72
+ tokens: number;
73
+ ms: number;
74
+ }
75
+
76
+ export function repairFloors(): RepairFloors {
77
+ return {
78
+ tokens: Math.max(0, envNumber("PI_HORDE_REPAIR_MIN_TOKENS", 40_000)),
79
+ ms: Math.max(0, envNumber("PI_HORDE_REPAIR_MIN_MS", 60_000)),
80
+ };
81
+ }
82
+
83
+ /** Demand units a default budget funds at the floor; the overrides name flat totals instead. */
84
+ const FUNDED_UNITS = 6;
85
+
86
+ export interface RepairBudget {
87
+ tokens: number;
88
+ ms: number;
89
+ /** Turns per attempt: one that has not finished its inbox read by then will not finish with more. */
90
+ turns: number;
91
+ queued: number;
92
+ }
93
+
94
+ /**
95
+ * Totals follow the queues: one floor per demand unit across the queued members up to
96
+ * `FUNDED_UNITS`, plus the two correction/recheck shares that `repairShare` reserves for later
97
+ * rounds, unless `PI_HORDE_REPAIR_TOKENS` or `PI_HORDE_REPAIR_MS` names a flat total. A flat 50k
98
+ * tokens funded two attempts in an eight-member run and skipped six members with queued work;
99
+ * one floor per member gave a one-review verifier and a seven-thread owner the same 60 seconds.
100
+ */
101
+ export function repairBudget(demands: number[], floors: RepairFloors, rounds: number): RepairBudget {
102
+ const units = demands.reduce((sum, demand) => sum + demand, 0);
103
+ const funded = Math.min(Math.max(1, units), FUNDED_UNITS) + Math.min(2, Math.max(0, rounds - 1));
104
+ return {
105
+ tokens: Math.max(0, envNumber("PI_HORDE_REPAIR_TOKENS", floors.tokens * funded)),
106
+ ms: Math.max(0, envNumber("PI_HORDE_REPAIR_MS", floors.ms * funded)),
107
+ turns: Math.max(1, envNumber("PI_HORDE_REPAIR_TURNS", 40)),
108
+ queued: demands.length,
109
+ };
110
+ }
111
+
112
+ type SkipReason = "insufficient-budget" | "insufficient-time";
113
+
114
+ async function recordSkipped(launch: RunLaunch, pending: MemberState[], reason: SkipReason, detail: Record<string, number>): Promise<void> {
45
115
  const { run } = launch;
46
116
  const ctx = { hordeDir: run.hordeDir, runId: run.id, actor: "parent", attempt: "scheduler" };
47
117
  const members = pending.map((member) => ({ member: member.name, messages: queueFor(launch, member).map((m) => m.id) }))
48
118
  .filter((entry) => entry.messages.length);
49
- if (members.length) await recordEvent(ctx, "repair-skipped", { payload: { reason: "insufficient-budget", ...detail, members } });
119
+ if (!members.length) return;
120
+ run.repairSkipped = [...new Set([...(run.repairSkipped ?? []), ...members.map((entry) => entry.member)])];
121
+ await recordEvent(ctx, "repair-skipped", { payload: { reason, ...detail, members } });
122
+ }
123
+
124
+ interface RepairLimits {
125
+ tokens: number;
126
+ turns: number;
127
+ }
128
+
129
+ /** What the attempt's first turn cost, so the token floor can be tuned from data. */
130
+ interface FirstTurnCost {
131
+ turns: number;
132
+ input: number;
133
+ output: number;
134
+ cacheRead: number;
135
+ cacheWrite: number;
50
136
  }
51
137
 
52
138
  /** A finished worker receives a fresh, bounded attempt with a precise evidence handoff. */
53
- async function repairMember(launch: RunLaunch, member: MemberState, deadline: number, tokens: number): Promise<void> {
139
+ async function repairMember(launch: RunLaunch, member: MemberState, deadline: number, limits: RepairLimits, floors: RepairFloors): Promise<void> {
54
140
  const { run } = launch;
55
141
  const ctx = { hordeDir: run.hordeDir, runId: run.id, actor: member.name };
56
142
  const queue = queueFor(launch, member);
@@ -60,27 +146,34 @@ async function repairMember(launch: RunLaunch, member: MemberState, deadline: nu
60
146
  member.repairs = (member.repairs ?? 0) + 1;
61
147
  member.repairStopReason = undefined;
62
148
  member.attempt = randomUUID();
63
- const allowance = { tokens, ms: Math.max(0, deadline - Date.now()) };
64
- member.resetNote = `Repair attempt ${member.repairs}. Discovery is closed. Budget: about ${tokens} input+output tokens and ${allowance.ms}ms. ` +
149
+ const allowance = { ...limits, ms: Math.max(0, deadline - Date.now()) };
150
+ let firstTurn: FirstTurnCost | undefined;
151
+ member.resetNote = `Repair attempt ${member.repairs}. Discovery is closed. Budget: about ${allowance.tokens} input+output tokens, ${allowance.turns} turns and ${allowance.ms}ms. ` +
65
152
  `Read horde inbox and referenced evidence. Work in this priority order: ${queue.map((m) => `${m.id} ${m.target} (${m.state})`).join(", ")}. ` +
66
153
  "Handle blockers and artifact reviews before administrative threads. For review requests, independently check the current revision against its artifact kind and criteria and record evidence plus a scoped review. " +
67
- "For corrections, acknowledge, repair, publish, and respond with the current hash. Requesters recheck and resolve or defer with evidence. Preserve unchecked limits. Do not reopen unrelated work.";
154
+ "For corrections, repair, publish, and respond with the current hash. Requesters recheck and resolve or defer with evidence. Preserve unchecked limits. Do not reopen unrelated work.";
155
+ const spent = (): number => member.usage.input + member.usage.output;
68
156
  const halt = (reason: NonNullable<MemberState["repairStopReason"]>) => {
69
157
  if (member.repairStopReason) return;
70
158
  member.repairStopReason = reason;
71
- fs.appendFileSync(path.join(run.runDir, `${member.name}.log`), `[horde] repair stop: ${reason}; allowance=${JSON.stringify(allowance)}; observed tokens=${member.usage.input + member.usage.output}\n`);
159
+ fs.appendFileSync(path.join(run.runDir, `${member.name}.log`), `[horde] repair stop: ${reason}; allowance=${JSON.stringify(allowance)}; observed tokens=${spent()} turns=${member.turns}\n`);
72
160
  hardStopMembers(run, [member.name]);
73
161
  };
74
162
  const check = () => {
163
+ if (firstTurn === undefined && member.turns > 0) {
164
+ const { input, output, cacheRead, cacheWrite } = member.usage;
165
+ firstTurn = { turns: member.turns, input, output, cacheRead, cacheWrite };
166
+ }
75
167
  const reason = stopReason(launch);
76
168
  if (reason) halt(reason);
77
- else if (member.usage.input + member.usage.output >= tokens) halt("tokens");
169
+ else if (spent() >= allowance.tokens) halt("tokens");
170
+ else if (member.turns >= allowance.turns) halt("turns");
78
171
  else if (Date.now() >= deadline) halt("time");
79
172
  };
80
173
  const timeout = setTimeout(() => halt("time"), Math.max(1, deadline - Date.now()));
81
174
  const watchdog = setInterval(check, 100);
82
175
  try {
83
- await recordEvent({ ...ctx, attempt: member.attempt }, "repair-start", { payload: { messages: queue.map((m) => m.id), prior, allowance } });
176
+ await recordEvent({ ...ctx, attempt: member.attempt }, "repair-start", { payload: { messages: queue.map((m) => m.id), demand: repairDemand(queue), prior, allowance, floors } });
84
177
  if (stopReason(launch) || Date.now() >= deadline) {
85
178
  halt(stopReason(launch) ?? "time");
86
179
  member.status = "stopped";
@@ -91,12 +184,13 @@ async function repairMember(launch: RunLaunch, member: MemberState, deadline: nu
91
184
  await spawnOne(launch, member, undefined, check);
92
185
  }
93
186
  await recordEvent({ ...ctx, attempt: member.attempt }, "repair-end", { payload: {
94
- cause: member.endCause, stopReason: member.repairStopReason, allowance, usage: member.usage, messages: queue.map((m) => m.id),
187
+ cause: member.endCause, stopReason: member.repairStopReason, allowance, usage: member.usage, turns: member.turns, firstTurn,
188
+ messages: queue.map((m) => m.id),
95
189
  } });
96
190
  } finally {
97
191
  clearTimeout(timeout);
98
192
  clearInterval(watchdog);
99
- run.repairTokens = (run.repairTokens ?? 0) + member.usage.input + member.usage.output;
193
+ run.repairTokens = (run.repairTokens ?? 0) + spent();
100
194
  member.turns += prior.turns;
101
195
  member.toolCalls += prior.toolCalls;
102
196
  for (const key of ["input", "output", "cacheRead", "cacheWrite", "cost"] as const) member.usage[key] += prior.usage[key];
@@ -109,10 +203,10 @@ export async function drainRepairs(launch: RunLaunch): Promise<void> {
109
203
  const { run } = launch;
110
204
  let started = false;
111
205
  // The repair phase is recorded only once an attempt or a budget refusal actually happens.
112
- const begin = async (): Promise<void> => {
206
+ const begin = async (budget: RepairBudget): Promise<void> => {
113
207
  if (started) return;
114
208
  started = true;
115
- await recordPhase(run, "repair", "start");
209
+ await recordPhase(run, "repair", "start", { budget });
116
210
  };
117
211
  try {
118
212
  await runRepairRounds(launch, begin);
@@ -121,35 +215,56 @@ export async function drainRepairs(launch: RunLaunch): Promise<void> {
121
215
  }
122
216
  }
123
217
 
124
- async function runRepairRounds(launch: RunLaunch, begin: () => Promise<void>): Promise<void> {
218
+ function eligibleMembers(launch: RunLaunch): Array<{ member: MemberState; queue: PeerMessage[] }> {
219
+ return launch.run.members.map((member) => ({ member, queue: queueFor(launch, member) })).filter((entry) => entry.queue.length)
220
+ .sort((a, b) => repairPriority(a.queue[0]!) - repairPriority(b.queue[0]!) ||
221
+ (a.member.repairs ?? 0) - (b.member.repairs ?? 0) || a.queue[0]!.createdAt.localeCompare(b.queue[0]!.createdAt));
222
+ }
223
+
224
+ async function runRepairRounds(launch: RunLaunch, begin: (budget: RepairBudget) => Promise<void>): Promise<void> {
125
225
  const { run } = launch;
126
226
  const rounds = Math.max(0, Math.min(6, envNumber("PI_HORDE_REPAIR_ROUNDS", 4)));
127
- const deadline = Date.now() + Math.max(0, envNumber("PI_HORDE_REPAIR_MS", 120_000));
128
- const tokenLimit = Math.max(0, envNumber("PI_HORDE_REPAIR_TOKENS", 50_000));
129
- const tokenFloor = Math.max(0, envNumber("PI_HORDE_REPAIR_MIN_TOKENS", 20_000));
130
- const exhausted = () => stopReason(launch) || Date.now() >= deadline || (run.repairTokens ?? 0) >= tokenLimit;
227
+ if (rounds === 0 || stopReason(launch)) return;
228
+ const floors = repairFloors();
229
+ await scheduleCollaboration(launch, { settleMs: 0, liveOnly: false });
230
+ const budget = repairBudget(eligibleMembers(launch).map((entry) => repairDemand(entry.queue)), floors, rounds);
231
+ if (budget.queued === 0) return;
232
+ const deadline = Date.now() + budget.ms;
233
+ const exhausted = () => stopReason(launch) || Date.now() >= deadline || (run.repairTokens ?? 0) >= budget.tokens;
131
234
  for (let round = 0; round < rounds; round++) {
132
235
  if (exhausted()) return;
133
- await scheduleCollaboration(launch, { settleMs: 0, liveOnly: false });
134
- const eligible = run.members.map((member) => ({ member, queue: queueFor(launch, member) })).filter((entry) => entry.queue.length)
135
- .sort((a, b) => repairPriority(a.queue[0]!) - repairPriority(b.queue[0]!) ||
136
- (a.member.repairs ?? 0) - (b.member.repairs ?? 0) || a.queue[0]!.createdAt.localeCompare(b.queue[0]!.createdAt));
137
- if (!eligible.length) return;
138
- // Sequential attempts preserve owner -> reviewer ordering while reserving others' shares.
139
- for (let index = 0; index < eligible.length; index++) {
236
+ if (round > 0) await scheduleCollaboration(launch, { settleMs: 0, liveOnly: false });
237
+ // One attempt per member per round, picked in priority order re-read before each attempt:
238
+ // a thread that became blocking during an attempt moves its recipient ahead of the
239
+ // administrative queues that were ordered first at round start.
240
+ const served = new Set<string>();
241
+ for (;;) {
140
242
  if (exhausted()) return;
141
- const pending = eligible.slice(index).filter(({ member }) => queueFor(launch, member).length);
142
- const remaining = tokenLimit - (run.repairTokens ?? 0);
143
- const tokens = repairAllowance(repairShare(remaining, pending.length, rounds - round - 1), remaining, tokenFloor);
144
- const ms = repairShare(deadline - Date.now(), pending.length, rounds - round - 1);
243
+ const pending = eligibleMembers(launch).filter((entry) => !served.has(entry.member.name));
244
+ if (!pending.length) break;
245
+ const next = pending[0]!;
246
+ const pendingUnits = pending.reduce((sum, entry) => sum + repairDemand(entry.queue), 0);
247
+ const units = repairDemand(next.queue);
248
+ const unserved = pending.map((entry) => entry.member);
249
+ const remaining = budget.tokens - (run.repairTokens ?? 0);
250
+ const laterRounds = rounds - round - 1;
251
+ const tokens = repairAllowance(repairShare(remaining, pendingUnits, laterRounds, units), remaining, floors.tokens);
145
252
  if (tokens === undefined) {
146
- await begin();
147
- await recordSkipped(launch, pending.map((entry) => entry.member), { remaining, floor: tokenFloor });
253
+ await begin(budget);
254
+ await recordSkipped(launch, unserved, "insufficient-budget", { remaining, floor: floors.tokens });
255
+ return;
256
+ }
257
+ const remainingMs = Math.max(0, deadline - Date.now());
258
+ const fundable = Math.floor(remaining / Math.max(1, floors.tokens));
259
+ const ms = repairAllowance(repairTimeShare(remainingMs, pendingUnits, laterRounds, fundable, units), remainingMs, floors.ms);
260
+ if (ms === undefined || ms < 1) {
261
+ await begin(budget);
262
+ await recordSkipped(launch, unserved, "insufficient-time", { remainingMs, floorMs: floors.ms });
148
263
  return;
149
264
  }
150
- if (ms < 1) break;
151
- await begin();
152
- await repairMember(launch, eligible[index]!.member, Date.now() + ms, tokens);
265
+ await begin(budget);
266
+ served.add(next.member.name);
267
+ await repairMember(launch, next.member, Date.now() + ms, { tokens, turns: budget.turns }, floors);
153
268
  }
154
269
  }
155
270
  }