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/README.md +11 -4
- package/docs/REFERENCE.md +15 -7
- package/package.json +1 -1
- package/src/citations.ts +86 -7
- package/src/collaboration-report.ts +19 -3
- package/src/dashboard-data.ts +16 -4
- package/src/dashboard-html.ts +20 -5
- package/src/ledger/collaboration.ts +10 -1
- package/src/ledger/event-metrics.ts +34 -0
- package/src/ledger/resources.ts +29 -0
- package/src/member.ts +1 -1
- package/src/prompt.ts +70 -15
- package/src/run/finalize.ts +40 -15
- package/src/run/repair.ts +124 -34
- package/src/run/solved.ts +3 -0
- package/src/run/types.ts +4 -0
- package/src/run-graph.ts +9 -3
- package/src/tool/actions.ts +10 -8
- package/src/tool/collaboration-actions.ts +43 -12
- package/src/tool/inbox.ts +10 -3
- package/src/tool/params.ts +5 -1
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. */
|
|
@@ -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:
|
|
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
|
|
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
|
|
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?:
|
|
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
|
|
554
|
-
|
|
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
|
-
`${
|
|
832
|
+
`${reuse ? ` (${reuse})` : ""}`,
|
|
778
833
|
);
|
|
779
834
|
}
|
|
780
835
|
}
|
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
|
@@ -32,25 +32,93 @@ export function repairShare(remaining: number, queued: number, laterRounds: numb
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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 = {
|
|
64
|
-
|
|
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,
|
|
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=${
|
|
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 (
|
|
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,
|
|
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) +
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
const
|
|
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 =
|
|
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 =
|
|
143
|
-
const
|
|
144
|
-
const
|
|
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:
|
|
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
|
-
|
|
151
|
-
await
|
|
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
|
}
|