agent-horde 0.1.3 → 0.1.5
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 +3 -2
- package/SECURITY.md +8 -0
- package/docs/REFERENCE.md +27 -5
- package/package.json +1 -1
- package/src/citations.ts +15 -7
- package/src/collaboration-report.ts +4 -0
- package/src/dashboard-export.ts +192 -0
- package/src/dashboard-html.ts +77 -18
- package/src/dashboard.ts +114 -12
- package/src/ledger/clusters.ts +3 -2
- package/src/ledger/collaboration.ts +13 -0
- package/src/ledger/event-metrics.ts +8 -7
- package/src/ledger/ideas.ts +11 -3
- package/src/ledger/run-signals.ts +5 -1
- package/src/prompt.ts +15 -12
- package/src/run/repair.ts +52 -27
- package/src/run-graph.ts +3 -2
- package/src/tool/actions.ts +31 -7
- package/src/tool/collaboration-actions.ts +44 -1
- package/src/tool/collaboration-target.ts +6 -1
- package/src/tool/params.ts +5 -1
package/src/dashboard.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
renderClaims,
|
|
30
30
|
renderCollaboration,
|
|
31
31
|
renderError,
|
|
32
|
+
renderExportStart,
|
|
32
33
|
renderFacts,
|
|
33
34
|
renderFile,
|
|
34
35
|
renderFindings,
|
|
@@ -45,8 +46,12 @@ import {
|
|
|
45
46
|
renderTranscriptEntry,
|
|
46
47
|
} from "./dashboard-html.js";
|
|
47
48
|
import type { RenderContext } from "./dashboard-html.js";
|
|
49
|
+
import { collect, exportDashboard } from "./dashboard-export.js";
|
|
50
|
+
import type { ExportPage } from "./dashboard-export.js";
|
|
48
51
|
|
|
49
52
|
const DEFAULT_PORT = 7433;
|
|
53
|
+
const EXPORT_PAGE = "/export";
|
|
54
|
+
const EXPORT_PATH = "/export.zip";
|
|
50
55
|
const REFRESH_SECONDS = 3;
|
|
51
56
|
const MAX_URL_LENGTH = 4096;
|
|
52
57
|
const CLOSE_GUARD_MS = 2_000;
|
|
@@ -57,6 +62,8 @@ interface DashboardResponse {
|
|
|
57
62
|
contentType: string;
|
|
58
63
|
body: string;
|
|
59
64
|
headers: Record<string, string>;
|
|
65
|
+
/** Written after the headers in place of `body`, as it is produced. */
|
|
66
|
+
stream?: AsyncIterable<Buffer> | undefined;
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
interface Route {
|
|
@@ -282,6 +289,12 @@ function respond(status: number, contentType: string, body: string, extra: Recor
|
|
|
282
289
|
return { status, contentType, body, headers: { ...baseHeaders(), ...extra } };
|
|
283
290
|
}
|
|
284
291
|
|
|
292
|
+
/** A same-origin dashboard path, else `/`; the export page returns here. */
|
|
293
|
+
function backPath(query: URLSearchParams): string {
|
|
294
|
+
const back = query.get("back") ?? "/";
|
|
295
|
+
return /^\/(?![/\\])[^\s]{0,2000}$/.test(back) ? back : "/";
|
|
296
|
+
}
|
|
297
|
+
|
|
285
298
|
function parentPath(pathname: string): string {
|
|
286
299
|
const trimmed = pathname.replace(/\/+$/, "");
|
|
287
300
|
const parent = trimmed.slice(0, trimmed.lastIndexOf("/"));
|
|
@@ -316,24 +329,33 @@ function routeMatch(pathname: string): { route: Route; params: string[] } | unde
|
|
|
316
329
|
return undefined;
|
|
317
330
|
}
|
|
318
331
|
|
|
319
|
-
|
|
332
|
+
/** The run this page belongs to, and the run the nav's collaboration and graph links target. */
|
|
333
|
+
function runContext(src: DashboardSource, url: URL, params: string[]): Pick<RenderContext, "runId" | "navRun"> {
|
|
334
|
+
const runId = RUN_ID_RE.test(params[0] ?? "") ? params[0] : undefined;
|
|
335
|
+
const filterRun = url.searchParams.get("run");
|
|
336
|
+
const navRun = runId ?? (filterRun && RUN_ID_RE.test(filterRun) ? filterRun : undefined) ?? latestRunId(src);
|
|
337
|
+
return { runId, navRun };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The export costs seconds of work per request, so pages from other sites may not start one. */
|
|
341
|
+
function handleRoute(src: DashboardSource, url: URL, json: boolean, ctx: RenderContext, crossSite: boolean): DashboardResponse {
|
|
320
342
|
const pathname = json ? url.pathname.slice("/api".length) || "/" : url.pathname;
|
|
343
|
+
if (!json && (pathname === EXPORT_PATH || pathname === EXPORT_PAGE)) {
|
|
344
|
+
if (crossSite) return errorResponse(403, "the export can only be started from the dashboard itself", url, json, ctx);
|
|
345
|
+
if (pathname === EXPORT_PATH) return exportResponse(src, url, ctx);
|
|
346
|
+
return respond(200, "text/html; charset=utf-8", renderExportStart(EXPORT_PATH, backPath(url.searchParams), ctx));
|
|
347
|
+
}
|
|
321
348
|
const matched = routeMatch(pathname);
|
|
322
349
|
if (!matched) throw new DashboardError(404, `no such page: ${pathname}`);
|
|
323
350
|
const { route, params } = matched;
|
|
324
351
|
const refreshParam = queryEnum(url.searchParams, "refresh", ["0", String(REFRESH_SECONDS)] as const, String(REFRESH_SECONDS));
|
|
325
352
|
const vm = route.load(src, params, url.searchParams);
|
|
326
353
|
if (json) return respond(200, "application/json; charset=utf-8", JSON.stringify(vm));
|
|
327
|
-
const runId = RUN_ID_RE.test(params[0] ?? "") ? params[0] : undefined;
|
|
328
|
-
const filterRun = url.searchParams.get("run");
|
|
329
|
-
const navRun = runId ?? (filterRun && RUN_ID_RE.test(filterRun) ? filterRun : undefined) ?? latestRunId(src);
|
|
330
354
|
const live = route.live(vm, src, params, url.searchParams);
|
|
331
355
|
const on = live && refreshParam !== "0";
|
|
332
356
|
const pageCtx: RenderContext = {
|
|
333
357
|
...ctx,
|
|
334
|
-
|
|
335
|
-
navRun,
|
|
336
|
-
href: url.pathname + url.search,
|
|
358
|
+
...runContext(src, url, params),
|
|
337
359
|
refresh: live ? { on, toggleHref: refreshToggle(url, on), seconds: REFRESH_SECONDS } : undefined,
|
|
338
360
|
};
|
|
339
361
|
const body = route.render(vm as never, pageCtx);
|
|
@@ -341,17 +363,68 @@ function handleRoute(src: DashboardSource, url: URL, json: boolean, ctx: RenderC
|
|
|
341
363
|
return respond(200, "text/html; charset=utf-8", body);
|
|
342
364
|
}
|
|
343
365
|
|
|
344
|
-
/**
|
|
345
|
-
|
|
366
|
+
/** One page of the static export; errors become pages so no exported link dangles. */
|
|
367
|
+
function exportPage(src: DashboardSource, url: URL): ExportPage {
|
|
368
|
+
const ctx: RenderContext = { activeId: src.getActive()?.id, href: url.pathname + url.search, export: true };
|
|
369
|
+
try {
|
|
370
|
+
const matched = routeMatch(url.pathname);
|
|
371
|
+
if (!matched) throw new DashboardError(404, `no such page: ${url.pathname}`);
|
|
372
|
+
const { route, params } = matched;
|
|
373
|
+
const vm = route.load(src, params, url.searchParams);
|
|
374
|
+
return { body: route.render(vm as never, { ...ctx, ...runContext(src, url, params) }), text: route.text };
|
|
375
|
+
} catch (error) {
|
|
376
|
+
const status = error instanceof DashboardError ? error.status : 500;
|
|
377
|
+
const message = error instanceof DashboardError ? error.message : `dashboard error: ${error instanceof Error ? error.message : String(error)}`;
|
|
378
|
+
return { body: renderError(status, message, parentPath(url.pathname), ctx) };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The whole dashboard as a zip of static pages, streamed as it is built, named for the moment it was taken. */
|
|
383
|
+
export function exportDashboardArchive(src: DashboardSource, now = new Date()): { filename: string; zip: AsyncGenerator<Buffer> } {
|
|
384
|
+
const folder = `horde-dashboard-${now.toISOString().slice(0, 19).replace(/[T:]/g, "-")}`;
|
|
385
|
+
return { filename: `${folder}.zip`, zip: exportDashboard((url) => exportPage(src, url), folder, now) };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The finished archive in one buffer. */
|
|
389
|
+
export async function exportDashboardBuffer(src: DashboardSource, now = new Date()): Promise<{ filename: string; zip: Buffer }> {
|
|
390
|
+
const { filename, zip } = exportDashboardArchive(src, now);
|
|
391
|
+
return { filename, zip: await collect(zip) };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let exporting = false;
|
|
395
|
+
|
|
396
|
+
/** Marks the export busy from its first chunk until the stream ends or its reader goes away. */
|
|
397
|
+
async function* exclusive(stream: AsyncIterable<Buffer>): AsyncGenerator<Buffer> {
|
|
398
|
+
exporting = true;
|
|
399
|
+
try {
|
|
400
|
+
yield* stream;
|
|
401
|
+
} finally {
|
|
402
|
+
exporting = false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Headers go out at once so the browser shows the download while the pages are still being packed; one at a time. */
|
|
407
|
+
function exportResponse(src: DashboardSource, url: URL, ctx: RenderContext): DashboardResponse {
|
|
408
|
+
if (exporting) return errorResponse(503, "an export is already in progress; wait for it to finish", url, false, ctx);
|
|
409
|
+
const { filename, zip } = exportDashboardArchive(src);
|
|
410
|
+
return { ...respond(200, "application/zip", "", { "Content-Disposition": `attachment; filename="${filename}"` }), stream: exclusive(zip) };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Pure request handler: validates method and host, dispatches, maps errors to responses. `fetchSite` is the Sec-Fetch-Site header. */
|
|
414
|
+
export function routeRequest(src: DashboardSource, method: string, url: URL, host: string | undefined, fetchSite?: string): DashboardResponse {
|
|
346
415
|
const json = url.pathname === "/api" || url.pathname.startsWith("/api/");
|
|
347
|
-
const ctx: RenderContext = {
|
|
416
|
+
const ctx: RenderContext = {
|
|
417
|
+
activeId: src.getActive()?.id,
|
|
418
|
+
origin: host ? `http://${host}` : undefined,
|
|
419
|
+
href: url.pathname + url.search,
|
|
420
|
+
};
|
|
348
421
|
try {
|
|
349
422
|
if (method !== "GET" && method !== "HEAD") {
|
|
350
423
|
return { ...errorResponse(405, `method ${method} not allowed`, url, json, ctx), headers: { ...baseHeaders(), Allow: "GET, HEAD" } };
|
|
351
424
|
}
|
|
352
425
|
if (!host || !HOST_RE.test(host)) return errorResponse(403, "host must be 127.0.0.1 or localhost", url, json, ctx);
|
|
353
426
|
if (url.href.length > MAX_URL_LENGTH) return errorResponse(414, "url too long", url, json, ctx);
|
|
354
|
-
return handleRoute(src, url, json, ctx);
|
|
427
|
+
return handleRoute(src, url, json, ctx, fetchSite === "cross-site");
|
|
355
428
|
} catch (error) {
|
|
356
429
|
if (error instanceof DashboardError) return errorResponse(error.status, error.message, url, json, ctx);
|
|
357
430
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -383,12 +456,41 @@ function listenError(error: NodeJS.ErrnoException, port: number): Error {
|
|
|
383
456
|
return new Error(`dashboard could not listen on ${port}: ${error.message}`);
|
|
384
457
|
}
|
|
385
458
|
|
|
459
|
+
const drained = (res: http.ServerResponse): Promise<void> =>
|
|
460
|
+
new Promise((resolve) => {
|
|
461
|
+
const done = (): void => {
|
|
462
|
+
res.off("drain", done);
|
|
463
|
+
res.off("close", done);
|
|
464
|
+
resolve();
|
|
465
|
+
};
|
|
466
|
+
res.once("drain", done);
|
|
467
|
+
res.once("close", done);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
/** Writes the chunks as they come, pausing for the client; a failure mid-stream cuts the connection. */
|
|
471
|
+
async function streamBody(res: http.ServerResponse, stream: AsyncIterable<Buffer>): Promise<void> {
|
|
472
|
+
try {
|
|
473
|
+
for await (const chunk of stream) {
|
|
474
|
+
if (res.destroyed) return;
|
|
475
|
+
if (!res.write(chunk)) await drained(res);
|
|
476
|
+
}
|
|
477
|
+
res.end();
|
|
478
|
+
} catch (error) {
|
|
479
|
+
res.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
386
483
|
function serve(src: DashboardSource, req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
387
484
|
req.resume();
|
|
388
485
|
res.on("error", () => {});
|
|
389
486
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
390
|
-
const
|
|
487
|
+
const fetchSite = req.headers["sec-fetch-site"];
|
|
488
|
+
const response = routeRequest(src, req.method ?? "GET", url, req.headers.host, Array.isArray(fetchSite) ? fetchSite[0] : fetchSite);
|
|
391
489
|
res.writeHead(response.status, { ...response.headers, "Content-Type": response.contentType });
|
|
490
|
+
if (response.stream && req.method !== "HEAD") {
|
|
491
|
+
void streamBody(res, response.stream);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
392
494
|
res.end(response.body);
|
|
393
495
|
}
|
|
394
496
|
|
package/src/ledger/clusters.ts
CHANGED
|
@@ -114,9 +114,10 @@ export function clusterFindings(entries: FindingEntry[]): FindingCluster[] {
|
|
|
114
114
|
return drafts
|
|
115
115
|
.filter((draft) => draft.findings.length > 0)
|
|
116
116
|
.map((draft) => {
|
|
117
|
-
// A cluster is represented by its root finding: a non-delta with the lowest id.
|
|
117
|
+
// A cluster is represented by its root finding: a current non-delta with the lowest id. A
|
|
118
|
+
// superseded root would file the whole cluster, successor included, under superseded.
|
|
118
119
|
const representative = [...draft.findings].sort((a, b) => {
|
|
119
|
-
const rank = (finding: FindingEntry): number => (finding.deltaOf === undefined ? 0 : 1);
|
|
120
|
+
const rank = (finding: FindingEntry): number => (finding.supersededBy === undefined ? 0 : 2) + (finding.deltaOf === undefined ? 0 : 1);
|
|
120
121
|
return rank(a) - rank(b) || a.id - b.id;
|
|
121
122
|
})[0]!;
|
|
122
123
|
const members: string[] = [];
|
|
@@ -100,10 +100,23 @@ export function projectCollaboration(events: HordeEvent[]): CollaborationState {
|
|
|
100
100
|
state.resources = state.resources.filter((r) => r.target !== contract.target).concat(contract);
|
|
101
101
|
}
|
|
102
102
|
if (p.transition) applyTransition(state, event);
|
|
103
|
+
if (Array.isArray(p.clearances)) applyClearances(state, event);
|
|
103
104
|
}
|
|
104
105
|
return state;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/** A full-scope review that covers a partial review's unchecked scope resolves the challenge that review raised. */
|
|
109
|
+
function applyClearances(state: CollaborationState, event: HordeEvent): void {
|
|
110
|
+
for (const clearance of event.payload.clearances as Array<{ id: string; text: string; refs: string[] }>) {
|
|
111
|
+
const message = state.messages.find((m) => m.id === clearance.id);
|
|
112
|
+
if (!message || message.state === "resolved") continue;
|
|
113
|
+
message.state = "resolved";
|
|
114
|
+
message.resolution = clearance.text;
|
|
115
|
+
message.resolutionEvidence = clearance.refs;
|
|
116
|
+
message.updatedAt = event.at;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
107
120
|
function applyTransition(state: CollaborationState, event: HordeEvent): void {
|
|
108
121
|
const t = event.payload.transition as { id: string; state: MessageState; text?: string; revision?: string; responseTarget?: string; attempt?: string };
|
|
109
122
|
const message = state.messages.find((m) => m.id === t.id);
|
|
@@ -3,17 +3,18 @@ import type { HordeEvent } from "./events.js";
|
|
|
3
3
|
export interface SolvedConfirmations {
|
|
4
4
|
/** Members whose confirm-solved was the one credited for a proposal revision. */
|
|
5
5
|
credited: string[];
|
|
6
|
-
/** Members
|
|
7
|
-
|
|
6
|
+
/** Members who confirmed the same proposal after the credited one; their text is in the event. */
|
|
7
|
+
additional: string[];
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
/** The report
|
|
10
|
+
/** The report names every member that confirmed the proposal, not only the one credited. */
|
|
11
11
|
export function solvedConfirmations(events: HordeEvent[]): SolvedConfirmations {
|
|
12
|
-
const out: SolvedConfirmations = { credited: [],
|
|
12
|
+
const out: SolvedConfirmations = { credited: [], additional: [] };
|
|
13
13
|
for (const event of events) {
|
|
14
|
-
if (event.action !== "confirm-solved") continue;
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
if (event.action !== "confirm-solved" || event.outcome === "rejected" || event.outcome === "intent") continue;
|
|
15
|
+
const result = event.payload.result as { confirmation?: unknown } | undefined;
|
|
16
|
+
if (event.outcome === "applied" && result?.confirmation !== "additional") out.credited.push(event.actor);
|
|
17
|
+
else out.additional.push(event.actor);
|
|
17
18
|
}
|
|
18
19
|
return out;
|
|
19
20
|
}
|
package/src/ledger/ideas.ts
CHANGED
|
@@ -215,18 +215,26 @@ export async function finishIdea(
|
|
|
215
215
|
});
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Release a claimed idea back to the pool, or retire an unclaimed one you added. `author` is the
|
|
220
|
+
* member whose write created the idea, when the caller knows it; an unclaimed idea is dropped only
|
|
221
|
+
* by its author, so folding your own duplicate does not require claiming it first.
|
|
222
|
+
*/
|
|
218
223
|
export async function dropIdea(
|
|
219
224
|
hordeDir: string,
|
|
220
225
|
owner: string,
|
|
221
226
|
id: number,
|
|
222
227
|
reason: string | undefined,
|
|
228
|
+
options: { author?: string | undefined } = {},
|
|
223
229
|
): Promise<Idea> {
|
|
224
230
|
return withHordeLock(hordeDir, owner, () => {
|
|
225
231
|
const doc = readIdeasDoc(hordeDir);
|
|
226
232
|
const { index, idea } = requireIdea(doc, id);
|
|
227
|
-
if (idea.owner !== owner) {
|
|
228
|
-
|
|
229
|
-
|
|
233
|
+
if (idea.owner && idea.owner !== owner) {
|
|
234
|
+
throw new Error(`horde: idea #${id} is claimed by ${idea.owner}; you can only drop ideas you claimed`);
|
|
235
|
+
}
|
|
236
|
+
if (!idea.owner && options.author !== owner) {
|
|
237
|
+
throw new Error(`horde: idea #${id} is not claimed; only the member who added it or its claimant can drop it`);
|
|
230
238
|
}
|
|
231
239
|
const dropped: Idea = { id, mark: " ", text: idea.text };
|
|
232
240
|
doc.lines[index] = formatIdea(dropped);
|
|
@@ -235,7 +235,11 @@ export async function confirmSolved(
|
|
|
235
235
|
if (state.proposal.member === member) return { ok: false, reason: "the proposer cannot validate their own proposal" };
|
|
236
236
|
if (revision && state.proposal.revision !== revision) return { ok: false, reason: "stale answer revision; inspect the current proposal" };
|
|
237
237
|
if (state.contest) return { ok: false, reason: `the proposal was already contested by ${state.contest.member}` };
|
|
238
|
-
if (state.confirm)
|
|
238
|
+
if (state.confirm) {
|
|
239
|
+
// The first confirmation credits the wind-down; later ones are still verification records.
|
|
240
|
+
appendEntry(hordePaths(hordeDir).board, boardEntry(member, `also validated SOLVED: ${requireText(note, "confirm-solved")}`));
|
|
241
|
+
return { ok: true, reason: "already validated", by: state.confirm.member };
|
|
242
|
+
}
|
|
239
243
|
const signal: SolvedSignal = {
|
|
240
244
|
member,
|
|
241
245
|
text: requireText(note, "confirm-solved"),
|
package/src/prompt.ts
CHANGED
|
@@ -98,7 +98,8 @@ Collaboration loop:
|
|
|
98
98
|
- Attach evidence to anyone's work: horde attach target=F# text=observation relation=supports|contradicts|extends,
|
|
99
99
|
optionally artifactPath=artifacts/file. Returned E# retains the full evidence. Ownership is unnecessary.
|
|
100
100
|
- horde message target=F#/A#/I#/R# kind=challenge|review-request|question|blocker|handoff|decision text=question,
|
|
101
|
-
to=member-N (default owner), refs=[E#].
|
|
101
|
+
to=member-N (default owner; a request about your own work names another member), refs=[E#].
|
|
102
|
+
Challenges/blockers default to blocking acceptance.
|
|
102
103
|
- Read horde inbox. Recipient: respond target=Q# revision=hash text=result; ack target=Q# text=plan first only to signal intent.
|
|
103
104
|
Respond with the challenged target's current revision, not another finding's hash. For an I# resolved by
|
|
104
105
|
a finding on that idea, explicitly set responseTarget=F#. Superseded F# requests follow the correction.
|
|
@@ -530,8 +531,8 @@ export interface RunReportInput extends RunSummaryInput {
|
|
|
530
531
|
deliverable?: string | undefined;
|
|
531
532
|
/** Shared resources claimed during the run, with the non-owners whose records cite each one. */
|
|
532
533
|
resources?: ReportResource[] | undefined;
|
|
533
|
-
/** Members credited with validating the proposal, and those
|
|
534
|
-
solvedConfirmations?: { credited: string[];
|
|
534
|
+
/** Members credited with validating the proposal, and those who confirmed it after that. */
|
|
535
|
+
solvedConfirmations?: { credited: string[]; additional: string[] } | undefined;
|
|
535
536
|
/** Idea creations this run made, and the ones dedup folded into an existing idea. */
|
|
536
537
|
ideaCreations?: { created: number; absorbed: Array<{ id: number; count: number }> } | undefined;
|
|
537
538
|
/** Set when a member reported the goal answered. */
|
|
@@ -603,9 +604,9 @@ function artifactDiagnosis(input: RunReportInput): string[] {
|
|
|
603
604
|
`- solved: ${input.solved.member} proposed the goal answered (${how}): ${oneLine(input.solved.text, 200)}`,
|
|
604
605
|
);
|
|
605
606
|
if (input.solvedConfirmations) {
|
|
606
|
-
const { credited,
|
|
607
|
-
const
|
|
608
|
-
lines.push(`- confirm-solved: ${credited.length
|
|
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}`);
|
|
609
610
|
}
|
|
610
611
|
lines.push("- answer: ANSWER.md (full answer text in this run directory)");
|
|
611
612
|
} else if (input.solvedContested) {
|
|
@@ -770,10 +771,13 @@ function findingLine(finding: FindingEntry, input: RunReportInput): string {
|
|
|
770
771
|
);
|
|
771
772
|
}
|
|
772
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
|
+
|
|
773
777
|
function findingsSection(input: RunReportInput, supersededBy: Map<number, number>): string[] {
|
|
774
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]));
|
|
775
779
|
const liveClusters = input.clusters.filter((cluster) => !supersededBy.has(cluster.representative.id)).sort((a, b) => reviewRank(b) - reviewRank(a));
|
|
776
|
-
const
|
|
780
|
+
const superseded = input.findings.filter((finding) => supersededBy.has(finding.id));
|
|
777
781
|
const lines = [
|
|
778
782
|
"",
|
|
779
783
|
"## Distinct findings (ranked by review status, then legacy/support ranking, then importance, " +
|
|
@@ -785,16 +789,15 @@ function findingsSection(input: RunReportInput, supersededBy: Map<number, number
|
|
|
785
789
|
const delta =
|
|
786
790
|
cluster.deltaMembers.length > 0 ? `; deltas: ${cluster.deltaMembers.join(", ")}` : "";
|
|
787
791
|
lines.push(
|
|
788
|
-
`${index + 1}. ${cluster.representative.title} ` +
|
|
792
|
+
`${index + 1}. ${oneLine(cluster.representative.title, HEADLINE_CHARS)} ` +
|
|
789
793
|
`(members: ${cluster.members.join(", ") || "none"}${delta}; ${cluster.findings.length} entries)`,
|
|
790
794
|
);
|
|
791
795
|
for (const finding of cluster.findings) lines.push(findingLine(finding, input));
|
|
792
796
|
});
|
|
793
|
-
if (
|
|
797
|
+
if (superseded.length > 0) {
|
|
794
798
|
lines.push("", "## Superseded by later findings", "");
|
|
795
|
-
for (const
|
|
796
|
-
|
|
797
|
-
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)}`);
|
|
798
801
|
}
|
|
799
802
|
}
|
|
800
803
|
return lines;
|
package/src/run/repair.ts
CHANGED
|
@@ -26,18 +26,34 @@ 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
51
|
* The clock is split only among the attempts the token budget can still pay for. Reserving time
|
|
36
52
|
* for members the token floor will skip anyway shrank every attempt to a share nobody could use.
|
|
37
53
|
*/
|
|
38
|
-
export function repairTimeShare(remainingMs: number,
|
|
39
|
-
const
|
|
40
|
-
return Math.max(0, Math.
|
|
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)));
|
|
41
57
|
}
|
|
42
58
|
|
|
43
59
|
/**
|
|
@@ -64,8 +80,8 @@ export function repairFloors(): RepairFloors {
|
|
|
64
80
|
};
|
|
65
81
|
}
|
|
66
82
|
|
|
67
|
-
/**
|
|
68
|
-
const
|
|
83
|
+
/** Demand units a default budget funds at the floor; the overrides name flat totals instead. */
|
|
84
|
+
const FUNDED_UNITS = 6;
|
|
69
85
|
|
|
70
86
|
export interface RepairBudget {
|
|
71
87
|
tokens: number;
|
|
@@ -76,18 +92,20 @@ export interface RepairBudget {
|
|
|
76
92
|
}
|
|
77
93
|
|
|
78
94
|
/**
|
|
79
|
-
* Totals follow the
|
|
80
|
-
* correction/recheck shares that `repairShare` reserves for later
|
|
81
|
-
* `PI_HORDE_REPAIR_TOKENS` or `PI_HORDE_REPAIR_MS` names a flat total. A flat 50k
|
|
82
|
-
* two attempts in an eight-member run and skipped six members with queued work
|
|
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.
|
|
83
100
|
*/
|
|
84
|
-
export function repairBudget(
|
|
85
|
-
const
|
|
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));
|
|
86
104
|
return {
|
|
87
105
|
tokens: Math.max(0, envNumber("PI_HORDE_REPAIR_TOKENS", floors.tokens * funded)),
|
|
88
106
|
ms: Math.max(0, envNumber("PI_HORDE_REPAIR_MS", floors.ms * funded)),
|
|
89
107
|
turns: Math.max(1, envNumber("PI_HORDE_REPAIR_TURNS", 40)),
|
|
90
|
-
queued,
|
|
108
|
+
queued: demands.length,
|
|
91
109
|
};
|
|
92
110
|
}
|
|
93
111
|
|
|
@@ -155,7 +173,7 @@ async function repairMember(launch: RunLaunch, member: MemberState, deadline: nu
|
|
|
155
173
|
const timeout = setTimeout(() => halt("time"), Math.max(1, deadline - Date.now()));
|
|
156
174
|
const watchdog = setInterval(check, 100);
|
|
157
175
|
try {
|
|
158
|
-
await recordEvent({ ...ctx, attempt: member.attempt }, "repair-start", { payload: { messages: queue.map((m) => m.id), prior, allowance, floors } });
|
|
176
|
+
await recordEvent({ ...ctx, attempt: member.attempt }, "repair-start", { payload: { messages: queue.map((m) => m.id), demand: repairDemand(queue), prior, allowance, floors } });
|
|
159
177
|
if (stopReason(launch) || Date.now() >= deadline) {
|
|
160
178
|
halt(stopReason(launch) ?? "time");
|
|
161
179
|
member.status = "stopped";
|
|
@@ -209,37 +227,44 @@ async function runRepairRounds(launch: RunLaunch, begin: (budget: RepairBudget)
|
|
|
209
227
|
if (rounds === 0 || stopReason(launch)) return;
|
|
210
228
|
const floors = repairFloors();
|
|
211
229
|
await scheduleCollaboration(launch, { settleMs: 0, liveOnly: false });
|
|
212
|
-
const budget = repairBudget(eligibleMembers(launch).
|
|
230
|
+
const budget = repairBudget(eligibleMembers(launch).map((entry) => repairDemand(entry.queue)), floors, rounds);
|
|
213
231
|
if (budget.queued === 0) return;
|
|
214
232
|
const deadline = Date.now() + budget.ms;
|
|
215
233
|
const exhausted = () => stopReason(launch) || Date.now() >= deadline || (run.repairTokens ?? 0) >= budget.tokens;
|
|
216
234
|
for (let round = 0; round < rounds; round++) {
|
|
217
235
|
if (exhausted()) return;
|
|
218
236
|
if (round > 0) await scheduleCollaboration(launch, { settleMs: 0, liveOnly: false });
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
|
|
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 (;;) {
|
|
223
242
|
if (exhausted()) return;
|
|
224
|
-
const pending =
|
|
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);
|
|
225
249
|
const remaining = budget.tokens - (run.repairTokens ?? 0);
|
|
226
250
|
const laterRounds = rounds - round - 1;
|
|
227
|
-
const tokens = repairAllowance(repairShare(remaining,
|
|
251
|
+
const tokens = repairAllowance(repairShare(remaining, pendingUnits, laterRounds, units), remaining, floors.tokens);
|
|
228
252
|
if (tokens === undefined) {
|
|
229
253
|
await begin(budget);
|
|
230
|
-
await recordSkipped(launch,
|
|
254
|
+
await recordSkipped(launch, unserved, "insufficient-budget", { remaining, floor: floors.tokens });
|
|
231
255
|
return;
|
|
232
256
|
}
|
|
233
257
|
const remainingMs = Math.max(0, deadline - Date.now());
|
|
234
258
|
const fundable = Math.floor(remaining / Math.max(1, floors.tokens));
|
|
235
|
-
const ms = repairAllowance(repairTimeShare(remainingMs,
|
|
259
|
+
const ms = repairAllowance(repairTimeShare(remainingMs, pendingUnits, laterRounds, fundable, units), remainingMs, floors.ms);
|
|
236
260
|
if (ms === undefined || ms < 1) {
|
|
237
261
|
await begin(budget);
|
|
238
|
-
await recordSkipped(launch,
|
|
262
|
+
await recordSkipped(launch, unserved, "insufficient-time", { remainingMs, floorMs: floors.ms });
|
|
239
263
|
return;
|
|
240
264
|
}
|
|
241
265
|
await begin(budget);
|
|
242
|
-
|
|
266
|
+
served.add(next.member.name);
|
|
267
|
+
await repairMember(launch, next.member, Date.now() + ms, { tokens, turns: budget.turns }, floors);
|
|
243
268
|
}
|
|
244
269
|
}
|
|
245
270
|
}
|
package/src/run-graph.ts
CHANGED
|
@@ -284,7 +284,8 @@ function solvedHandler(label: string, tone: EdgeTone): Handler {
|
|
|
284
284
|
draft.lastSolved = marker;
|
|
285
285
|
return;
|
|
286
286
|
}
|
|
287
|
-
|
|
287
|
+
const additional = result.confirmation === "additional";
|
|
288
|
+
if (draft.lastSolved) draft.edges.push({ from: marker, to: draft.lastSolved, label: additional ? `${label} (additional)` : label, tone });
|
|
288
289
|
};
|
|
289
290
|
}
|
|
290
291
|
|
|
@@ -494,7 +495,7 @@ function addRefused(draft: Draft, event: HordeEvent): void {
|
|
|
494
495
|
addEdge(draft, marker, refusedTarget(event.action, input), event.action, "negative");
|
|
495
496
|
}
|
|
496
497
|
|
|
497
|
-
/** A no-op that still names a result (a duplicate idea
|
|
498
|
+
/** A no-op that still names a result (a duplicate idea) is a repeat, not a refusal. */
|
|
498
499
|
function isRepeat(event: HordeEvent): boolean {
|
|
499
500
|
return event.outcome === "noop" && record(event.payload.result).noop === true;
|
|
500
501
|
}
|
package/src/tool/actions.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { validateFindingResourceCitations } from "../citations.js";
|
|
|
13
13
|
import { idleHint } from "./hints.js";
|
|
14
14
|
import type { HordeToolParams } from "./params.js";
|
|
15
15
|
import { COLLABORATION_ACTIONS, runCollaborationAction } from "./collaboration-actions.js";
|
|
16
|
-
import { recordEvent } from "../ledger/events.js";
|
|
16
|
+
import { readEvents, recordEvent } from "../ledger/events.js";
|
|
17
17
|
import type { EventContext } from "../ledger/events.js";
|
|
18
18
|
|
|
19
19
|
const CLEAN_STOP_NOTICE =
|
|
@@ -30,8 +30,10 @@ const WORK_PLAN_POST =
|
|
|
30
30
|
interface ActionResult {
|
|
31
31
|
text: string;
|
|
32
32
|
id?: number;
|
|
33
|
-
/** True when the call changed nothing
|
|
33
|
+
/** True when the call changed nothing, such as a duplicate idea. */
|
|
34
34
|
noop?: boolean;
|
|
35
|
+
/** For confirm-solved: whether this confirmation validated the proposal or joined an earlier one. */
|
|
36
|
+
confirmation?: "credited" | "additional";
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
interface ActionContext {
|
|
@@ -52,6 +54,20 @@ function requireId(params: HordeToolParams): number {
|
|
|
52
54
|
return params.id;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/** The result line of a closed idea: `text`, or `note` when a member reached for that field instead. */
|
|
58
|
+
function closeNote(params: HordeToolParams): string | undefined {
|
|
59
|
+
return params.text ?? params.note;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The member whose `idea` write created #id in this run, from the event log; undefined outside a run. */
|
|
63
|
+
function ideaAuthor(hordeDir: string, runId: string | undefined, id: number): string | undefined {
|
|
64
|
+
if (!runId) return undefined;
|
|
65
|
+
const created = readEvents(hordeDir, runId).find(
|
|
66
|
+
(event) => event.action === "idea" && event.outcome === "applied" && (event.payload.result as { id?: unknown } | undefined)?.id === id,
|
|
67
|
+
);
|
|
68
|
+
return created?.actor;
|
|
69
|
+
}
|
|
70
|
+
|
|
55
71
|
function requireRun(ctx: ActionContext): string {
|
|
56
72
|
if (!ctx.runId) throw new Error(`horde: ${ctx.params.action} needs an active run`);
|
|
57
73
|
return ctx.runId;
|
|
@@ -283,6 +299,7 @@ async function claimAction({ hordeDir, member, params }: ActionContext): Promise
|
|
|
283
299
|
|
|
284
300
|
async function resourceAction(ctx: ActionContext): Promise<ActionResult> {
|
|
285
301
|
const { hordeDir, member, params, runId } = ctx;
|
|
302
|
+
if ((params.text ?? "").trim() === "") throw new Error("horde: resource needs its name in text (note holds the reuse pointer)");
|
|
286
303
|
const { resource, reused, ownedByCaller } = await claimResource(hordeDir, member, {
|
|
287
304
|
name: params.text,
|
|
288
305
|
note: params.note,
|
|
@@ -341,9 +358,15 @@ async function solvedAction(ctx: ActionContext): Promise<ActionResult> {
|
|
|
341
358
|
async function confirmSolvedAction(ctx: ActionContext): Promise<ActionResult> {
|
|
342
359
|
const answer = await confirmSolved(ctx.hordeDir, requireRun(ctx), ctx.member, ctx.params.text, ctx.params.revision);
|
|
343
360
|
if (!answer.ok) throw new Error(`horde: ${answer.reason}`);
|
|
344
|
-
if (answer.reason !== "validated")
|
|
361
|
+
if (answer.reason !== "validated") {
|
|
362
|
+
return {
|
|
363
|
+
text: `Recorded your confirmation; the proposal was already validated by ${answer.by}. Finish your claimed work.`,
|
|
364
|
+
confirmation: "additional",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
345
367
|
return {
|
|
346
368
|
text: "Validated the SOLVED proposal. Finish your claimed work; the parent will wind down once any coverage requirement is satisfied.",
|
|
369
|
+
confirmation: "credited",
|
|
347
370
|
};
|
|
348
371
|
}
|
|
349
372
|
|
|
@@ -357,19 +380,20 @@ async function contestSolvedAction(ctx: ActionContext): Promise<ActionResult> {
|
|
|
357
380
|
|
|
358
381
|
async function doneAction(ctx: ActionContext): Promise<ActionResult> {
|
|
359
382
|
const { hordeDir, member, params } = ctx;
|
|
360
|
-
const idea = await finishIdea(hordeDir, member, requireId(params), params
|
|
383
|
+
const idea = await finishIdea(hordeDir, member, requireId(params), closeNote(params));
|
|
361
384
|
return closedIdea(ctx, idea, `Done #${idea.id}: ${idea.text}`);
|
|
362
385
|
}
|
|
363
386
|
|
|
364
387
|
async function dropAction(ctx: ActionContext): Promise<ActionResult> {
|
|
365
|
-
const { hordeDir, member, params } = ctx;
|
|
366
|
-
const
|
|
388
|
+
const { hordeDir, member, params, runId } = ctx;
|
|
389
|
+
const id = requireId(params);
|
|
390
|
+
const idea = await dropIdea(hordeDir, member, id, closeNote(params), { author: ideaAuthor(hordeDir, runId, id) });
|
|
367
391
|
return closedIdea(ctx, idea, `Dropped #${idea.id}: ${idea.text}`);
|
|
368
392
|
}
|
|
369
393
|
|
|
370
394
|
async function ruleOutAction(ctx: ActionContext): Promise<ActionResult> {
|
|
371
395
|
const { hordeDir, member, params } = ctx;
|
|
372
|
-
const idea = await ruleOutIdea(hordeDir, member, requireId(params), params
|
|
396
|
+
const idea = await ruleOutIdea(hordeDir, member, requireId(params), closeNote(params));
|
|
373
397
|
return closedIdea(ctx, idea, `Ruled out #${idea.id}: ${idea.note ?? "dead end"}`);
|
|
374
398
|
}
|
|
375
399
|
|