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/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
- function handleRoute(src: DashboardSource, url: URL, json: boolean, ctx: RenderContext): DashboardResponse {
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
- runId,
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
- /** Pure request handler: validates method and host, dispatches, maps errors to responses. */
345
- export function routeRequest(src: DashboardSource, method: string, url: URL, host: string | undefined): DashboardResponse {
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 = { activeId: src.getActive()?.id, origin: host ? `http://${host}` : undefined };
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 response = routeRequest(src, req.method ?? "GET", url, req.headers.host);
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
 
@@ -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 whose confirm-solved arrived after the credited one and changed nothing. */
7
- redundant: string[];
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 shows how many members spent a confirmation on an already validated proposal. */
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: [], redundant: [] };
12
+ const out: SolvedConfirmations = { credited: [], additional: [] };
13
13
  for (const event of events) {
14
- if (event.action !== "confirm-solved") continue;
15
- if (event.outcome === "applied") out.credited.push(event.actor);
16
- else if (event.outcome === "noop") out.redundant.push(event.actor);
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
  }
@@ -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
- const holder = idea.owner ? `claimed by ${idea.owner}` : "not claimed";
229
- throw new Error(`horde: idea #${id} is ${holder}; you can only drop ideas you claimed`);
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) return { ok: true, reason: "already validated", by: state.confirm.member };
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#]. Challenges/blockers default to blocking acceptance.
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 whose confirmation came after it. */
534
- solvedConfirmations?: { credited: string[]; redundant: string[] } | undefined;
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, 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)}`);
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 supersededClusters = input.clusters.filter((cluster) => supersededBy.has(cluster.representative.id));
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 (supersededClusters.length > 0) {
797
+ if (superseded.length > 0) {
794
798
  lines.push("", "## Superseded by later findings", "");
795
- for (const cluster of supersededClusters) {
796
- const by = supersededBy.get(cluster.representative.id);
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
- /** Reserve shares for the other queued members and up to two correction/recheck turns. */
30
- export function repairShare(remaining: number, queued: number, laterRounds: number): number {
31
- return Math.max(0, Math.floor(remaining / (Math.max(1, queued) + Math.min(2, Math.max(0, laterRounds)))));
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, 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))));
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
- /** Queued members a default budget funds at the floor; the overrides name flat totals instead. */
68
- const FUNDED_ATTEMPTS = 4;
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 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.
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(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));
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).length, floors, rounds);
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
- const eligible = eligibleMembers(launch);
220
- if (!eligible.length) return;
221
- // Sequential attempts preserve owner -> reviewer ordering while reserving others' shares.
222
- for (let index = 0; index < eligible.length; index++) {
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 = eligible.slice(index).filter(({ member }) => queueFor(launch, member).length);
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, pending.length, laterRounds), remaining, floors.tokens);
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, pending.map((entry) => entry.member), "insufficient-budget", { remaining, floor: floors.tokens });
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, pending.length, laterRounds, fundable), remainingMs, floors.ms);
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, pending.map((entry) => entry.member), "insufficient-time", { remainingMs, floorMs: floors.ms });
262
+ await recordSkipped(launch, unserved, "insufficient-time", { remainingMs, floorMs: floors.ms });
239
263
  return;
240
264
  }
241
265
  await begin(budget);
242
- await repairMember(launch, eligible[index]!.member, Date.now() + ms, { tokens, turns: budget.turns }, floors);
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
- if (draft.lastSolved) draft.edges.push({ from: marker, to: draft.lastSolved, label: event.outcome === "noop" ? `${label} (redundant)` : label, tone });
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, a redundant confirmation) is a repeat, not a refusal. */
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
  }
@@ -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: a duplicate idea, or a confirmation after the credited one. */
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") return { text: `The SOLVED proposal was already validated by ${answer.by}.`, noop: true };
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.text);
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 idea = await dropIdea(hordeDir, member, requireId(params), params.text);
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.text);
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