agent-horde 0.1.2 → 0.1.3

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/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. */
@@ -88,13 +99,14 @@ Collaboration loop:
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
101
  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.
102
+ - Read horde inbox. Recipient: respond target=Q# revision=hash text=result; ack target=Q# text=plan first only to signal intent.
92
103
  Respond with the challenged target's current revision, not another finding's hash. For an I# resolved by
93
104
  a finding on that idea, explicitly set responseTarget=F#. Superseded F# requests follow the correction.
94
105
  Requester: attach/review evidence on the current response target, then resolve target=Q# refs=[E#]
95
106
  revision=current-hash text=decision, or defer with a reason. Disclosed finding limitations remain debt.
96
107
  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.
108
+ A deferred blocking issue stays visible as acceptance debt; its recipient can still respond to it and
109
+ the requester then resolves it. Never silently ignore a peer's failed test.
98
110
  - Publish runnable outputs with horde artifact artifactKind=runnable artifactPath=artifacts/file
99
111
  criteria=positive-and-negative-tests dependsOn=[F#]. Assessment documents use artifactKind=document
100
112
  and criteria covering citation accuracy, claim scope and disclosed limits. Full independent sourced/derived
@@ -351,14 +363,38 @@ function memberSummarySection(
351
363
  return lines;
352
364
  }
353
365
 
366
+ /**
367
+ * How the run ended, from what the parent recorded rather than from the solved flag alone: a
368
+ * proposal can be validated and still not wind the run down when coverage or review debt holds
369
+ * it, and the repair phase can end the run on budget instead.
370
+ */
371
+ export function runEndSentence(run: RunSummaryInput): string {
372
+ const parts: string[] = [];
373
+ if (run.solved) {
374
+ const at = run.solved.at ? ` at ${formatClock(new Date(run.solved.at))}` : "";
375
+ const validated = run.solvedValidatedBy ? `validated by ${run.solvedValidatedBy}` : "not validated by another member";
376
+ const wound = run.solvedCommitted ? "the run wound down on it" : "the run did not wind down on it";
377
+ parts.push(`${run.solved.member} proposed the goal solved${at}, ${validated}; ${wound}.`);
378
+ }
379
+ parts.push(run.stopped ? `Run ${run.stopLabel ?? "was stopped by the user"}.` : "Run ended when every member exited.");
380
+ const attempts = run.members.reduce((n, m) => n + (m.repairs ?? 0), 0);
381
+ const killed = run.members.filter((m) => ["tokens", "turns", "time"].includes(m.repairStopReason ?? ""));
382
+ const skipped = run.repairSkipped ?? [];
383
+ if (attempts > 0 || skipped.length > 0) {
384
+ const detail = [
385
+ killed.length ? `${killed.length} killed on budget (${killed.map((m) => `${m.name}: ${m.repairStopReason}`).join(", ")})` : "",
386
+ skipped.length ? `${skipped.length} skipped for budget (${skipped.join(", ")})` : "",
387
+ ].filter(Boolean).join(", ");
388
+ parts.push(`Repair: ${attempts} attempt(s)${detail ? `, ${detail}` : ""}.`);
389
+ }
390
+ if (run.outcome) parts.push(`Outcome: ${run.outcome}.`);
391
+ return parts.join(" ");
392
+ }
393
+
354
394
  export function summaryMarkdown(run: RunSummaryInput, now: number = Date.now()): string {
355
395
  const counts = statusCounts(run.members);
356
396
  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
- : "";
397
+ const end = ` ${runEndSentence(run)}`;
362
398
  const lines = [
363
399
  `# Horde run ${run.id}`,
364
400
  "",
@@ -492,14 +528,16 @@ export interface RunReportInput extends RunSummaryInput {
492
528
  facts: FactEntry[];
493
529
  /** Merged deliverable markdown when one was generated; REPORT.md points at it. */
494
530
  deliverable?: string | undefined;
495
- /** Shared resources claimed during the run. */
496
- resources?: ResourceEntry[] | undefined;
531
+ /** Shared resources claimed during the run, with the non-owners whose records cite each one. */
532
+ resources?: ReportResource[] | undefined;
533
+ /** Members credited with validating the proposal, and those whose confirmation came after it. */
534
+ solvedConfirmations?: { credited: string[]; redundant: string[] } | undefined;
535
+ /** Idea creations this run made, and the ones dedup folded into an existing idea. */
536
+ ideaCreations?: { created: number; absorbed: Array<{ id: number; count: number }> } | undefined;
497
537
  /** Set when a member reported the goal answered. */
498
538
  solved?: SolvedSignal | undefined;
499
539
  /** True when a SOLVED proposal was contested and no validated answer replaced it. */
500
540
  solvedContested?: boolean | undefined;
501
- /** The other member that validated the solved proposal, if any. */
502
- solvedValidatedBy?: string | undefined;
503
541
  /** Resource teardown results from finalize. */
504
542
  cleanups?: Array<{ name: string; command: string; status: CleanupStatus; detail: string }> | undefined;
505
543
  /** Member board posts that landed after the last finding. */
@@ -550,8 +588,10 @@ function artifactDiagnosis(input: RunReportInput): string[] {
550
588
  lines.push("- deliverable: DELIVERABLE.md (merged task deliverable in this run directory)");
551
589
  }
552
590
  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)`);
591
+ const reclaims = input.resources.reduce((n, resource) => n + resource.reusers.length, 0);
592
+ const citing = new Set(input.resources.flatMap((resource) => resource.citedBy ?? []));
593
+ const reused = input.resources.filter((resource) => resource.reusers.length > 0 || (resource.citedBy ?? []).length > 0).length;
594
+ lines.push(`- resources: ${input.resources.length} claimed, ${reused} reused (${reclaims} explicit reclaim(s), ${citing.size} citing member(s))`);
555
595
  }
556
596
  if (input.solved) {
557
597
  const how = input.solvedValidatedBy
@@ -562,6 +602,11 @@ function artifactDiagnosis(input: RunReportInput): string[] {
562
602
  lines.push(
563
603
  `- solved: ${input.solved.member} proposed the goal answered (${how}): ${oneLine(input.solved.text, 200)}`,
564
604
  );
605
+ if (input.solvedConfirmations) {
606
+ const { credited, redundant } = input.solvedConfirmations;
607
+ const names = (members: string[]): string => (members.length ? ` (${members.join(", ")})` : "");
608
+ lines.push(`- confirm-solved: ${credited.length} credited${names(credited)}, ${redundant.length} redundant${names(redundant)}`);
609
+ }
565
610
  lines.push("- answer: ANSWER.md (full answer text in this run directory)");
566
611
  } else if (input.solvedContested) {
567
612
  lines.push("- solved: contested; no validated answer survived the run");
@@ -663,6 +708,12 @@ function ledgerDiagnosis(input: RunReportInput, supersededBy: Map<number, number
663
708
  .join(", ")}`,
664
709
  );
665
710
  }
711
+ if (input.ideaCreations) {
712
+ const { created, absorbed } = input.ideaCreations;
713
+ const total = absorbed.reduce((n, entry) => n + entry.count, 0);
714
+ const detail = absorbed.length ? ` (${absorbed.map((entry) => `#${entry.id} x${entry.count}`).join(", ")})` : "";
715
+ lines.push(`- ideas: ${created} created, ${total} duplicate creation(s) absorbed${detail}`);
716
+ }
666
717
  return lines;
667
718
  }
668
719
 
@@ -771,10 +822,14 @@ function runArtifactSections(input: RunReportInput): string[] {
771
822
  if (input.resources && input.resources.length > 0) {
772
823
  lines.push("", "## Resources", "");
773
824
  for (const resource of input.resources) {
825
+ const reuse = [
826
+ resource.reusers.length > 0 ? `reused by ${resource.reusers.join(", ")}` : "",
827
+ (resource.citedBy ?? []).length > 0 ? `cited by ${(resource.citedBy ?? []).join(", ")}` : "",
828
+ ].filter(Boolean).join("; ");
774
829
  lines.push(
775
830
  `- R${resource.id} ${resource.name} @${resource.owner}` +
776
831
  `${resource.note ? `: ${resource.note}` : ""}` +
777
- `${resource.reusers.length > 0 ? ` (reused by ${resource.reusers.join(", ")})` : ""}`,
832
+ `${reuse ? ` (${reuse})` : ""}`,
778
833
  );
779
834
  }
780
835
  }
@@ -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
@@ -32,25 +32,93 @@ export function repairShare(remaining: number, queued: number, laterRounds: numb
32
32
  }
33
33
 
34
34
  /**
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.
35
+ * The clock is split only among the attempts the token budget can still pay for. Reserving time
36
+ * for members the token floor will skip anyway shrank every attempt to a share nobody could use.
38
37
  */
39
- function repairAllowance(share: number, remaining: number, floor: number): number | undefined {
38
+ export function repairTimeShare(remainingMs: number, queued: number, laterRounds: number, fundable: number): number {
39
+ const reserved = Math.max(1, queued) + Math.min(2, Math.max(0, laterRounds));
40
+ return Math.max(0, Math.floor(remainingMs / Math.min(reserved, Math.max(1, fundable))));
41
+ }
42
+
43
+ /**
44
+ * A fresh repair session pays the full system prompt, tool schemas, task and queued inbox before
45
+ * it can act: about 20k input+output tokens over five turns on deepseek for a queue of two to
46
+ * four threads, and the better part of a minute to read, inspect, attach and review. A share
47
+ * below either floor is killed before it acts, so each attempt gets at least the floor and
48
+ * lower-priority members go unserved instead.
49
+ */
50
+ export function repairAllowance(share: number, remaining: number, floor: number): number | undefined {
40
51
  if (remaining < Math.max(1, floor)) return undefined;
41
52
  return Math.max(share, floor);
42
53
  }
43
54
 
44
- async function recordSkipped(launch: RunLaunch, pending: MemberState[], detail: Record<string, number>): Promise<void> {
55
+ export interface RepairFloors {
56
+ tokens: number;
57
+ ms: number;
58
+ }
59
+
60
+ export function repairFloors(): RepairFloors {
61
+ return {
62
+ tokens: Math.max(0, envNumber("PI_HORDE_REPAIR_MIN_TOKENS", 40_000)),
63
+ ms: Math.max(0, envNumber("PI_HORDE_REPAIR_MIN_MS", 60_000)),
64
+ };
65
+ }
66
+
67
+ /** Queued members a default budget funds at the floor; the overrides name flat totals instead. */
68
+ const FUNDED_ATTEMPTS = 4;
69
+
70
+ export interface RepairBudget {
71
+ tokens: number;
72
+ ms: number;
73
+ /** Turns per attempt: one that has not finished its inbox read by then will not finish with more. */
74
+ turns: number;
75
+ queued: number;
76
+ }
77
+
78
+ /**
79
+ * Totals follow the queue: one floor per queued member up to `FUNDED_ATTEMPTS`, plus the two
80
+ * correction/recheck shares that `repairShare` reserves for later rounds, unless
81
+ * `PI_HORDE_REPAIR_TOKENS` or `PI_HORDE_REPAIR_MS` names a flat total. A flat 50k tokens funded
82
+ * two attempts in an eight-member run and skipped six members with queued work.
83
+ */
84
+ export function repairBudget(queued: number, floors: RepairFloors, rounds: number): RepairBudget {
85
+ const funded = Math.min(Math.max(1, queued), FUNDED_ATTEMPTS) + Math.min(2, Math.max(0, rounds - 1));
86
+ return {
87
+ tokens: Math.max(0, envNumber("PI_HORDE_REPAIR_TOKENS", floors.tokens * funded)),
88
+ ms: Math.max(0, envNumber("PI_HORDE_REPAIR_MS", floors.ms * funded)),
89
+ turns: Math.max(1, envNumber("PI_HORDE_REPAIR_TURNS", 40)),
90
+ queued,
91
+ };
92
+ }
93
+
94
+ type SkipReason = "insufficient-budget" | "insufficient-time";
95
+
96
+ async function recordSkipped(launch: RunLaunch, pending: MemberState[], reason: SkipReason, detail: Record<string, number>): Promise<void> {
45
97
  const { run } = launch;
46
98
  const ctx = { hordeDir: run.hordeDir, runId: run.id, actor: "parent", attempt: "scheduler" };
47
99
  const members = pending.map((member) => ({ member: member.name, messages: queueFor(launch, member).map((m) => m.id) }))
48
100
  .filter((entry) => entry.messages.length);
49
- if (members.length) await recordEvent(ctx, "repair-skipped", { payload: { reason: "insufficient-budget", ...detail, members } });
101
+ if (!members.length) return;
102
+ run.repairSkipped = [...new Set([...(run.repairSkipped ?? []), ...members.map((entry) => entry.member)])];
103
+ await recordEvent(ctx, "repair-skipped", { payload: { reason, ...detail, members } });
104
+ }
105
+
106
+ interface RepairLimits {
107
+ tokens: number;
108
+ turns: number;
109
+ }
110
+
111
+ /** What the attempt's first turn cost, so the token floor can be tuned from data. */
112
+ interface FirstTurnCost {
113
+ turns: number;
114
+ input: number;
115
+ output: number;
116
+ cacheRead: number;
117
+ cacheWrite: number;
50
118
  }
51
119
 
52
120
  /** 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> {
121
+ async function repairMember(launch: RunLaunch, member: MemberState, deadline: number, limits: RepairLimits, floors: RepairFloors): Promise<void> {
54
122
  const { run } = launch;
55
123
  const ctx = { hordeDir: run.hordeDir, runId: run.id, actor: member.name };
56
124
  const queue = queueFor(launch, member);
@@ -60,27 +128,34 @@ async function repairMember(launch: RunLaunch, member: MemberState, deadline: nu
60
128
  member.repairs = (member.repairs ?? 0) + 1;
61
129
  member.repairStopReason = undefined;
62
130
  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. ` +
131
+ const allowance = { ...limits, ms: Math.max(0, deadline - Date.now()) };
132
+ let firstTurn: FirstTurnCost | undefined;
133
+ member.resetNote = `Repair attempt ${member.repairs}. Discovery is closed. Budget: about ${allowance.tokens} input+output tokens, ${allowance.turns} turns and ${allowance.ms}ms. ` +
65
134
  `Read horde inbox and referenced evidence. Work in this priority order: ${queue.map((m) => `${m.id} ${m.target} (${m.state})`).join(", ")}. ` +
66
135
  "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.";
136
+ "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.";
137
+ const spent = (): number => member.usage.input + member.usage.output;
68
138
  const halt = (reason: NonNullable<MemberState["repairStopReason"]>) => {
69
139
  if (member.repairStopReason) return;
70
140
  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`);
141
+ 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
142
  hardStopMembers(run, [member.name]);
73
143
  };
74
144
  const check = () => {
145
+ if (firstTurn === undefined && member.turns > 0) {
146
+ const { input, output, cacheRead, cacheWrite } = member.usage;
147
+ firstTurn = { turns: member.turns, input, output, cacheRead, cacheWrite };
148
+ }
75
149
  const reason = stopReason(launch);
76
150
  if (reason) halt(reason);
77
- else if (member.usage.input + member.usage.output >= tokens) halt("tokens");
151
+ else if (spent() >= allowance.tokens) halt("tokens");
152
+ else if (member.turns >= allowance.turns) halt("turns");
78
153
  else if (Date.now() >= deadline) halt("time");
79
154
  };
80
155
  const timeout = setTimeout(() => halt("time"), Math.max(1, deadline - Date.now()));
81
156
  const watchdog = setInterval(check, 100);
82
157
  try {
83
- await recordEvent({ ...ctx, attempt: member.attempt }, "repair-start", { payload: { messages: queue.map((m) => m.id), prior, allowance } });
158
+ await recordEvent({ ...ctx, attempt: member.attempt }, "repair-start", { payload: { messages: queue.map((m) => m.id), prior, allowance, floors } });
84
159
  if (stopReason(launch) || Date.now() >= deadline) {
85
160
  halt(stopReason(launch) ?? "time");
86
161
  member.status = "stopped";
@@ -91,12 +166,13 @@ async function repairMember(launch: RunLaunch, member: MemberState, deadline: nu
91
166
  await spawnOne(launch, member, undefined, check);
92
167
  }
93
168
  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),
169
+ cause: member.endCause, stopReason: member.repairStopReason, allowance, usage: member.usage, turns: member.turns, firstTurn,
170
+ messages: queue.map((m) => m.id),
95
171
  } });
96
172
  } finally {
97
173
  clearTimeout(timeout);
98
174
  clearInterval(watchdog);
99
- run.repairTokens = (run.repairTokens ?? 0) + member.usage.input + member.usage.output;
175
+ run.repairTokens = (run.repairTokens ?? 0) + spent();
100
176
  member.turns += prior.turns;
101
177
  member.toolCalls += prior.toolCalls;
102
178
  for (const key of ["input", "output", "cacheRead", "cacheWrite", "cost"] as const) member.usage[key] += prior.usage[key];
@@ -109,10 +185,10 @@ export async function drainRepairs(launch: RunLaunch): Promise<void> {
109
185
  const { run } = launch;
110
186
  let started = false;
111
187
  // The repair phase is recorded only once an attempt or a budget refusal actually happens.
112
- const begin = async (): Promise<void> => {
188
+ const begin = async (budget: RepairBudget): Promise<void> => {
113
189
  if (started) return;
114
190
  started = true;
115
- await recordPhase(run, "repair", "start");
191
+ await recordPhase(run, "repair", "start", { budget });
116
192
  };
117
193
  try {
118
194
  await runRepairRounds(launch, begin);
@@ -121,35 +197,49 @@ export async function drainRepairs(launch: RunLaunch): Promise<void> {
121
197
  }
122
198
  }
123
199
 
124
- async function runRepairRounds(launch: RunLaunch, begin: () => Promise<void>): Promise<void> {
200
+ function eligibleMembers(launch: RunLaunch): Array<{ member: MemberState; queue: PeerMessage[] }> {
201
+ return launch.run.members.map((member) => ({ member, queue: queueFor(launch, member) })).filter((entry) => entry.queue.length)
202
+ .sort((a, b) => repairPriority(a.queue[0]!) - repairPriority(b.queue[0]!) ||
203
+ (a.member.repairs ?? 0) - (b.member.repairs ?? 0) || a.queue[0]!.createdAt.localeCompare(b.queue[0]!.createdAt));
204
+ }
205
+
206
+ async function runRepairRounds(launch: RunLaunch, begin: (budget: RepairBudget) => Promise<void>): Promise<void> {
125
207
  const { run } = launch;
126
208
  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;
209
+ if (rounds === 0 || stopReason(launch)) return;
210
+ const floors = repairFloors();
211
+ await scheduleCollaboration(launch, { settleMs: 0, liveOnly: false });
212
+ const budget = repairBudget(eligibleMembers(launch).length, floors, rounds);
213
+ if (budget.queued === 0) return;
214
+ const deadline = Date.now() + budget.ms;
215
+ const exhausted = () => stopReason(launch) || Date.now() >= deadline || (run.repairTokens ?? 0) >= budget.tokens;
131
216
  for (let round = 0; round < rounds; round++) {
132
217
  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));
218
+ if (round > 0) await scheduleCollaboration(launch, { settleMs: 0, liveOnly: false });
219
+ const eligible = eligibleMembers(launch);
137
220
  if (!eligible.length) return;
138
221
  // Sequential attempts preserve owner -> reviewer ordering while reserving others' shares.
139
222
  for (let index = 0; index < eligible.length; index++) {
140
223
  if (exhausted()) return;
141
224
  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);
225
+ const remaining = budget.tokens - (run.repairTokens ?? 0);
226
+ const laterRounds = rounds - round - 1;
227
+ const tokens = repairAllowance(repairShare(remaining, pending.length, laterRounds), remaining, floors.tokens);
145
228
  if (tokens === undefined) {
146
- await begin();
147
- await recordSkipped(launch, pending.map((entry) => entry.member), { remaining, floor: tokenFloor });
229
+ await begin(budget);
230
+ await recordSkipped(launch, pending.map((entry) => entry.member), "insufficient-budget", { remaining, floor: floors.tokens });
231
+ return;
232
+ }
233
+ const remainingMs = Math.max(0, deadline - Date.now());
234
+ const fundable = Math.floor(remaining / Math.max(1, floors.tokens));
235
+ const ms = repairAllowance(repairTimeShare(remainingMs, pending.length, laterRounds, fundable), remainingMs, floors.ms);
236
+ if (ms === undefined || ms < 1) {
237
+ await begin(budget);
238
+ await recordSkipped(launch, pending.map((entry) => entry.member), "insufficient-time", { remainingMs, floorMs: floors.ms });
148
239
  return;
149
240
  }
150
- if (ms < 1) break;
151
- await begin();
152
- await repairMember(launch, eligible[index]!.member, Date.now() + ms, tokens);
241
+ await begin(budget);
242
+ await repairMember(launch, eligible[index]!.member, Date.now() + ms, { tokens, turns: budget.turns }, floors);
153
243
  }
154
244
  }
155
245
  }
package/src/run/solved.ts CHANGED
@@ -32,6 +32,7 @@ function unresolvedCoverageIdeas(run: HordeRun): number[] {
32
32
  function adoptProposal(run: HordeRun, proposal: SolvedSignal): void {
33
33
  run.solved = proposal;
34
34
  run.solvedValidatedBy = undefined;
35
+ run.solvedCommitted = false;
35
36
  run.solvedContested = false;
36
37
  run.solvedVerificationNotice = undefined;
37
38
  void recordLifecycle(run, "solved-proposed", { member: proposal.member, text: oneLine(proposal.text, 200) });
@@ -58,6 +59,7 @@ function dropContested(run: HordeRun, contest: SolvedSignal): void {
58
59
  if (!run.stopped) clearCleanStop(run.runDir);
59
60
  run.solved = undefined;
60
61
  run.solvedValidatedBy = undefined;
62
+ run.solvedCommitted = false;
61
63
  run.solvedCoverageNotice = undefined;
62
64
  run.solvedVerificationNotice = undefined;
63
65
  run.solvedContested = true;
@@ -128,6 +130,7 @@ function commitSolved(
128
130
  validator: string | undefined,
129
131
  ): void {
130
132
  run.solvedValidatedBy = validator;
133
+ run.solvedCommitted = true;
131
134
  void recordLifecycle(run, "solved-validated", { validator });
132
135
  safeNotify(
133
136
  run,
package/src/run/types.ts CHANGED
@@ -32,6 +32,8 @@ export interface HordeRun extends MemberRunInfo {
32
32
  acceptance?: string;
33
33
  snapshotHash?: string;
34
34
  repairTokens?: number;
35
+ /** Members whose queued repair work was skipped because the budget could not fund an attempt. */
36
+ repairSkipped?: string[];
35
37
  /** True when a user command or session shutdown ended the run. */
36
38
  userStop?: boolean | undefined;
37
39
  /** Human-readable stop reason for the report, e.g. "stopped by user". */
@@ -61,6 +63,8 @@ export interface HordeRun extends MemberRunInfo {
61
63
  solved?: SolvedSignal | undefined;
62
64
  /** Peer confirmation of the current proposal, independent of final acceptance. */
63
65
  solvedValidatedBy?: string | undefined;
66
+ /** True once the parent wound the run down on the validated proposal. */
67
+ solvedCommitted?: boolean | undefined;
64
68
  /** True when the last SOLVED proposal was contested and no validated answer replaced it. */
65
69
  solvedContested?: boolean | undefined;
66
70
  /** True once the parent has warned that a validated solve still has unverified findings. */
package/src/run-graph.ts CHANGED
@@ -242,10 +242,11 @@ function creates(letter: string, kind: GraphNodeKind, repeat: string, links: (dr
242
242
  return;
243
243
  }
244
244
  const nodeId = `${letter}${id}`;
245
- if (addEntity(draft, event, { id: nodeId, kind, label: nodeId, title: ownTitle(input, result) })) {
245
+ if (event.outcome === "applied" && addEntity(draft, event, { id: nodeId, kind, label: nodeId, title: ownTitle(input, result) })) {
246
246
  links(draft, nodeId, input);
247
247
  return;
248
248
  }
249
+ ensureTarget(draft, nodeId);
249
250
  addEdge(draft, addMarker(draft, event, `${event.action} (${repeat})`, describe(input, result)), nodeId, repeat, "structure");
250
251
  };
251
252
  }
@@ -283,7 +284,7 @@ function solvedHandler(label: string, tone: EdgeTone): Handler {
283
284
  draft.lastSolved = marker;
284
285
  return;
285
286
  }
286
- if (draft.lastSolved) draft.edges.push({ from: marker, to: draft.lastSolved, label, tone });
287
+ if (draft.lastSolved) draft.edges.push({ from: marker, to: draft.lastSolved, label: event.outcome === "noop" ? `${label} (redundant)` : label, tone });
287
288
  };
288
289
  }
289
290
 
@@ -493,8 +494,13 @@ function addRefused(draft: Draft, event: HordeEvent): void {
493
494
  addEdge(draft, marker, refusedTarget(event.action, input), event.action, "negative");
494
495
  }
495
496
 
497
+ /** A no-op that still names a result (a duplicate idea, a redundant confirmation) is a repeat, not a refusal. */
498
+ function isRepeat(event: HordeEvent): boolean {
499
+ return event.outcome === "noop" && record(event.payload.result).noop === true;
500
+ }
501
+
496
502
  function addEvent(draft: Draft, event: HordeEvent): void {
497
- if (event.outcome !== "applied") {
503
+ if (event.outcome !== "applied" && !isRepeat(event)) {
498
504
  addRefused(draft, event);
499
505
  return;
500
506
  }