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