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/README.md +11 -4
- package/docs/REFERENCE.md +19 -7
- package/package.json +1 -1
- package/src/citations.ts +98 -11
- package/src/collaboration-report.ts +23 -3
- package/src/dashboard-data.ts +16 -4
- package/src/dashboard-html.ts +20 -5
- package/src/ledger/clusters.ts +3 -2
- package/src/ledger/collaboration.ts +23 -1
- package/src/ledger/event-metrics.ts +35 -0
- package/src/ledger/ideas.ts +11 -3
- package/src/ledger/resources.ts +29 -0
- package/src/ledger/run-signals.ts +5 -1
- package/src/member.ts +1 -1
- package/src/prompt.ts +80 -22
- package/src/run/finalize.ts +40 -15
- package/src/run/repair.ts +156 -41
- package/src/run/solved.ts +3 -0
- package/src/run/types.ts +4 -0
- package/src/run-graph.ts +10 -3
- package/src/tool/actions.ts +39 -13
- package/src/tool/collaboration-actions.ts +87 -13
- package/src/tool/collaboration-target.ts +6 -1
- package/src/tool/inbox.ts +10 -3
- package/src/tool/params.ts +10 -2
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
|
|
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#].
|
|
91
|
-
|
|
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
|
|
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
|
|
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?:
|
|
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
|
|
554
|
-
|
|
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
|
|
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 (
|
|
797
|
+
if (superseded.length > 0) {
|
|
743
798
|
lines.push("", "## Superseded by later findings", "");
|
|
744
|
-
for (const
|
|
745
|
-
|
|
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
|
-
`${
|
|
835
|
+
`${reuse ? ` (${reuse})` : ""}`,
|
|
778
836
|
);
|
|
779
837
|
}
|
|
780
838
|
}
|
package/src/run/finalize.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
133
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
529
|
-
|
|
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
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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 = {
|
|
64
|
-
|
|
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,
|
|
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=${
|
|
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 (
|
|
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,
|
|
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) +
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
const
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 =
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
const
|
|
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,
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
await repairMember(launch,
|
|
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
|
}
|