agent-horde 0.2.0 → 0.3.0
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 +13 -2
- package/docs/REFERENCE.md +36 -9
- package/package.json +2 -2
- package/src/args.ts +30 -4
- package/src/citations.ts +16 -5
- package/src/dashboard-data.ts +31 -4
- package/src/dashboard-html.ts +4 -0
- package/src/format.ts +5 -0
- package/src/ledger/findings.ts +25 -6
- package/src/member.ts +22 -7
- package/src/prompt.ts +34 -7
- package/src/run/budget.ts +30 -0
- package/src/run/completions.ts +6 -1
- package/src/run/controls.ts +2 -1
- package/src/run/finalize.ts +15 -13
- package/src/run/launch.ts +2 -1
- package/src/run/repair.ts +138 -26
- package/src/run/setup.ts +3 -1
- package/src/run/solved.ts +21 -8
- package/src/run/supervise.ts +3 -2
- package/src/run/swarm-finalize.ts +3 -0
- package/src/run/swarm-report.ts +2 -1
- package/src/run/swarm.ts +2 -1
- package/src/run/types.ts +8 -0
- package/src/tool/actions.ts +7 -7
- package/src/tool/collaboration-actions.ts +31 -6
- package/src/tool/params.ts +14 -2
- package/src/widget.ts +12 -2
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.
|
|
93
|
+
pi install git:github.com/eliyastein/agent-horde@v0.3.0
|
|
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
|
|
@@ -137,6 +137,16 @@ extend, stress-test, or deepen the work.
|
|
|
137
137
|
/agent-horde --dashboard --until-stop 8 find every way an unauthenticated request reaches the database
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
+
`--budget=<usd>` puts a dollar ceiling on a run: once the members' combined spend, counted across
|
|
141
|
+
every session they have had, reaches it, the parent performs a clean stop on its own and the
|
|
142
|
+
report says `stopped at budget`. It pairs with `--until-stop` to bound an open-ended run. The
|
|
143
|
+
spend counted is what the members' model calls report; the planner, deliverable and post-mortem
|
|
144
|
+
helpers are not included, and members still get the clean-stop grace period to wrap up.
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
/agent-horde --until-stop --budget=20 8 find every way an unauthenticated request reaches the database
|
|
148
|
+
```
|
|
149
|
+
|
|
140
150
|
A count of `H/M` runs a swarm: H hordes of M members on one task, at most four hordes. A planner
|
|
141
151
|
helper first splits the task into H disjoint workstreams with acceptance criteria and each horde
|
|
142
152
|
works only its slice, with its own ledgers under `.horde/swarms/<id>/hN/`; if the planner cannot
|
|
@@ -145,7 +155,8 @@ the planner and deals the layers round-robin to the hordes. Members read the oth
|
|
|
145
155
|
rule-outs and resources through the `horde` tool but write only to their own horde. The hordes
|
|
146
156
|
start a few seconds apart, run the normal lifecycle independently, and when the last one has
|
|
147
157
|
finished a synthesis helper merges their deliverables into the swarm's `DELIVERABLE.md`, each
|
|
148
|
-
claim attributed to its horde. `--until-stop` applies to every horde
|
|
158
|
+
claim attributed to its horde. `--until-stop` applies to every horde, and `--budget` is one ceiling
|
|
159
|
+
for all hordes combined.
|
|
149
160
|
|
|
150
161
|
```
|
|
151
162
|
/agent-horde --dashboard=1234 3/12 audit the service for authorization bugs
|
package/docs/REFERENCE.md
CHANGED
|
@@ -7,7 +7,7 @@ at one problem beat a single linear one: research, audits, design exploration, R
|
|
|
7
7
|
## Usage
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
/agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] <count> <prompt>
|
|
10
|
+
/agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] [--budget=<usd>] <count> <prompt>
|
|
11
11
|
/agent-horde plan [--dashboard] <count> <layer; layer; ...>
|
|
12
12
|
/agent-horde status [target]
|
|
13
13
|
/agent-horde stop-hard [target] SIGTERM, SIGKILL after 5s (alias: stop)
|
|
@@ -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
|
|
@@ -61,7 +68,14 @@ unfinished ideas return to the open pool.
|
|
|
61
68
|
A clean stop still produces the deliverable; add `postmortem` to also run the reviewer. Runs
|
|
62
69
|
that end on their own (all members finished, solved, canary or provider failure) get both.
|
|
63
70
|
`PI_HORDE_MAX_MINUTES` forces a clean stop at a wall-clock deadline, reported as
|
|
64
|
-
`stopped at deadline`.
|
|
71
|
+
`stopped at deadline`. `--budget=<usd>` forces one when the members' combined spend reaches that
|
|
72
|
+
many dollars, reported as `stopped at budget`; in a swarm the ceiling covers every horde and every
|
|
73
|
+
horde still working is stopped. The check runs on every usage update a member reports, so it fires
|
|
74
|
+
on the model call that crosses the line, during discovery or repair alike. The spend counted is the
|
|
75
|
+
members' own, summed over every session a member slot has had (resets, continuations and repair
|
|
76
|
+
attempts included); the planner, deliverable, post-mortem and merge helpers are not counted, and
|
|
77
|
+
the clean-stop grace period lets members finish, so the final total overshoots the ceiling by what
|
|
78
|
+
the wind-down costs.
|
|
65
79
|
|
|
66
80
|
Members and helper agents are children of the pi session. Quitting or reloading pi sends every
|
|
67
81
|
live child SIGTERM, and each child polls `PI_HORDE_PARENT_PID` every two seconds and exits on its
|
|
@@ -225,9 +239,12 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
|
|
|
225
239
|
other member running, or no validation within `PI_HORDE_SOLVED_CONFIRM_MS`, an uncontested
|
|
226
240
|
proposal commits as `solved-unvalidated`. Findings no other member reproduced hold wind-down
|
|
227
241
|
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`)
|
|
242
|
+
every finding and artifact it cites. An answer that cites a superseded F# or quotes a provisional
|
|
243
|
+
T# is refused at `solved` and at `confirm-solved`; a pending proposal whose cited finding is
|
|
244
|
+
superseded or amended later, or whose cited A# is republished, is withdrawn (`solved-withdrawn`)
|
|
245
|
+
for re-proposal, and ANSWER.md's status says so instead of keeping an earlier validation. At
|
|
246
|
+
finalize, ANSWER.md's status names every citation that changed after the proposal pinned it. A
|
|
247
|
+
contest that names idea ids reopens the proposal under a new revision
|
|
231
248
|
(`solved-reopened`) once every named idea is done or ruled out, with the citations it pinned;
|
|
232
249
|
if a cited finding changed or a quoted fact is still provisional by then, it is withdrawn instead. Repair serves a request about
|
|
233
250
|
the pending answer (`target=answer`) with the blockers.
|
|
@@ -248,7 +265,10 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
|
|
|
248
265
|
still `respond` to a deferred request, and the requester then resolves it. A challenger can
|
|
249
266
|
withdraw its own blocking challenge on an F# or A# before any response by recording a
|
|
250
267
|
supporting review of the current revision and resolving with evidence on it; limits that
|
|
251
|
-
review discloses remain acceptance debt.
|
|
268
|
+
review discloses remain acceptance debt. A blocking request about the sender's own work (a
|
|
269
|
+
re-review after a correction) resolves on the recipient's supporting review of the current
|
|
270
|
+
revision, full-scope for an A#, since the owner may not review it. Acceptance names an open
|
|
271
|
+
blocker as `blocked by Q#`.
|
|
252
272
|
- **Verifier.** The verifier's idea claims are refused while other members' findings await its
|
|
253
273
|
review at their current revision; the pending F# list follows every review, reattest and board
|
|
254
274
|
post. A verifier whose session closes with critical or high-importance findings unreviewed is
|
|
@@ -290,7 +310,11 @@ resets in the report. A session that finished within `PI_HORDE_CONTINUE_COOLDOWN
|
|
|
290
310
|
60000) of starting waits out the remainder first, so a member that finds nothing to do cannot spin.
|
|
291
311
|
Crash and stall resets apply as usual. `stop-clean` and `stop-hard` end the run as usual and
|
|
292
312
|
`stop-clean` produces the report, deliverable, and postmortem; the last banked answer stands unless
|
|
293
|
-
a later proposal was validated too.
|
|
313
|
+
a later proposal was validated too. `--budget=<usd>` bounds such a run by spend (see Stopping).
|
|
314
|
+
|
|
315
|
+
Usage totals in the widget, status, dashboard and reports are summed over every session each
|
|
316
|
+
member slot has had, so a reset or continuation never hides what the replaced session spent. The
|
|
317
|
+
per-session figures stay in the `member-exit` events and the per-member report sections.
|
|
294
318
|
|
|
295
319
|
## Horde of hordes
|
|
296
320
|
|
|
@@ -350,7 +374,9 @@ for an abnormal end after measurable work; distinct finding clusters with review
|
|
|
350
374
|
superseded findings in their own section, and any resets; claim states; this run's facts split
|
|
351
375
|
from carried context; resources and cleanup results; coverage (released and still-open ideas,
|
|
352
376
|
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
|
|
377
|
+
mixed-concern clusters, unknown R# citations, and cited paths that do not exist (rooted URL paths
|
|
378
|
+
and templates with a date or placeholder slot, such as `logs/gate-YYYY-MM-DD.jsonl` or
|
|
379
|
+
`out/<id>.json`, are not checked). A killed
|
|
354
380
|
member's summary is reconstructed from its last board post. The message injected into the parent
|
|
355
381
|
carries a one-line diagnosis and, when generated, the post-mortem text.
|
|
356
382
|
|
|
@@ -441,6 +467,7 @@ run/ the parent-side run lifecycle, sharing one HordeState
|
|
|
441
467
|
swarm-report.ts per-horde totals: the swarm REPORT.md and the injected message
|
|
442
468
|
launch.ts dashboard, deadline, partition, staggered canary preflights, fan-out
|
|
443
469
|
supervise.ts member spawn, reset on exit, stall detection
|
|
470
|
+
budget.ts `--budget`: swarm-wide spend check, clean stop when it is reached
|
|
444
471
|
schedule.ts artifact review assignment and superseded-dependency handoffs
|
|
445
472
|
repair.ts bounded post-discovery reviews, owner repair, and recheck attempts
|
|
446
473
|
solved.ts two-phase SOLVED poll
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-horde",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Run a horde of pi agents on one task: shared ledgers, peer review, an auditable event log, and a live dashboard",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"typebox": "*"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@earendil-works/pi-coding-agent": "1.2
|
|
53
|
+
"@earendil-works/pi-coding-agent": "1.5.2",
|
|
54
54
|
"@types/node": "26.6.5",
|
|
55
55
|
"oxlint": "1.88.0",
|
|
56
56
|
"typescript": "7.0.2"
|
package/src/args.ts
CHANGED
|
@@ -4,7 +4,7 @@ export const MAX_MEMBERS = 12;
|
|
|
4
4
|
export const MAX_HORDES = 4;
|
|
5
5
|
|
|
6
6
|
export const USAGE = [
|
|
7
|
-
"usage: /agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] <count> <prompt>",
|
|
7
|
+
"usage: /agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] [--budget=<usd>] <count> <prompt>",
|
|
8
8
|
" spawn <count> members with the same prompt;",
|
|
9
9
|
" <count> may be H/M: H hordes of M members, the",
|
|
10
10
|
" task partitioned between the hordes by a planner",
|
|
@@ -15,7 +15,7 @@ export const USAGE = [
|
|
|
15
15
|
" additionally run a post-mortem on the run",
|
|
16
16
|
" /agent-horde pause [target] suspend members (SIGSTOP)",
|
|
17
17
|
" /agent-horde resume [target] resume suspended members (SIGCONT)",
|
|
18
|
-
|
|
18
|
+
" /agent-horde plan [--dashboard[=<port>]] [--no-verifier] [--until-stop] [--budget=<usd>] <count> <a; b; ...>",
|
|
19
19
|
" partition members across workstreams; with H/M",
|
|
20
20
|
" the layers go round-robin to the hordes",
|
|
21
21
|
" /agent-horde help show this text",
|
|
@@ -30,6 +30,8 @@ export const USAGE = [
|
|
|
30
30
|
"--until-stop keeps the whole horde working until you run stop-clean or stop-hard: a",
|
|
31
31
|
"validated solve is banked as the current answer and a member that finishes gets a fresh",
|
|
32
32
|
"session told to add new ideas.",
|
|
33
|
+
"--budget=<usd> clean-stops the run (every horde of a swarm) once the members' combined",
|
|
34
|
+
"spend reaches that many dollars.",
|
|
33
35
|
].join("\n");
|
|
34
36
|
|
|
35
37
|
/** One token of a control target: a horde, a member in every horde, or a member of one horde. */
|
|
@@ -72,6 +74,8 @@ type HordeArgs =
|
|
|
72
74
|
noVerifier?: boolean | undefined;
|
|
73
75
|
/** Present (true) only when `--until-stop` was given. */
|
|
74
76
|
untilStop?: boolean | undefined;
|
|
77
|
+
/** Dollar spend at which the run clean-stops itself; present only when `--budget` was given. */
|
|
78
|
+
budget?: number | undefined;
|
|
75
79
|
};
|
|
76
80
|
|
|
77
81
|
export type HordeRunArgs = Extract<HordeArgs, { kind: "run" }>;
|
|
@@ -155,7 +159,9 @@ function parseStopRest(
|
|
|
155
159
|
const DASHBOARD_FLAG = /^--dashboard(?:=(\S*))?(?=\s|$)/;
|
|
156
160
|
const NO_VERIFIER_FLAG = /^--no-verifier(?=\s|$)/;
|
|
157
161
|
const UNTIL_STOP_FLAG = /^--until-stop(?=\s|$)/;
|
|
162
|
+
const BUDGET_FLAG = /^--budget(?:=(\S*)|\s+(\S+))?(?=\s|$)/;
|
|
158
163
|
const DASHBOARD_ERROR = "invalid --dashboard; use --dashboard or --dashboard=<port 1-65535>";
|
|
164
|
+
const BUDGET_ERROR = "invalid --budget; use --budget=<usd>, a positive dollar amount";
|
|
159
165
|
|
|
160
166
|
/** Launch flags found at one position of the command; `rest` is the remaining text. */
|
|
161
167
|
interface LaunchFlags {
|
|
@@ -163,6 +169,7 @@ interface LaunchFlags {
|
|
|
163
169
|
dashboard?: DashboardOption | undefined;
|
|
164
170
|
noVerifier?: boolean | undefined;
|
|
165
171
|
untilStop?: boolean | undefined;
|
|
172
|
+
budget?: number | undefined;
|
|
166
173
|
error?: string | undefined;
|
|
167
174
|
}
|
|
168
175
|
|
|
@@ -174,7 +181,14 @@ function parseDashboardFlag(port: string | undefined): DashboardOption | { error
|
|
|
174
181
|
return { port: value };
|
|
175
182
|
}
|
|
176
183
|
|
|
177
|
-
|
|
184
|
+
function parseBudgetFlag(amount: string | undefined): number | { error: string } {
|
|
185
|
+
if (amount === undefined || !/^\d+(?:\.\d+)?$/.test(amount)) return { error: BUDGET_ERROR };
|
|
186
|
+
const value = Number(amount);
|
|
187
|
+
if (value <= 0) return { error: BUDGET_ERROR };
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Strip leading `--dashboard[=<port>]`, `--no-verifier`, `--until-stop` and `--budget` tokens, in any order. */
|
|
178
192
|
function takeLaunchFlags(text: string): LaunchFlags {
|
|
179
193
|
const flags: LaunchFlags = { rest: text.trim() };
|
|
180
194
|
for (;;) {
|
|
@@ -186,6 +200,14 @@ function takeLaunchFlags(text: string): LaunchFlags {
|
|
|
186
200
|
flags.rest = flags.rest.slice(dashboard[0].length).trim();
|
|
187
201
|
continue;
|
|
188
202
|
}
|
|
203
|
+
const budget = BUDGET_FLAG.exec(flags.rest);
|
|
204
|
+
if (budget) {
|
|
205
|
+
const value = parseBudgetFlag(budget[1] ?? budget[2]);
|
|
206
|
+
if (typeof value !== "number") return { ...flags, error: value.error };
|
|
207
|
+
flags.budget = value;
|
|
208
|
+
flags.rest = flags.rest.slice(budget[0].length).trim();
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
189
211
|
const noVerifier = NO_VERIFIER_FLAG.exec(flags.rest);
|
|
190
212
|
if (noVerifier) {
|
|
191
213
|
flags.noVerifier = true;
|
|
@@ -209,6 +231,7 @@ function mergeLaunchFlags(leading: LaunchFlags, trailing: LaunchFlags): LaunchFl
|
|
|
209
231
|
dashboard: leading.dashboard ?? trailing.dashboard,
|
|
210
232
|
noVerifier: leading.noVerifier === true || trailing.noVerifier === true ? true : undefined,
|
|
211
233
|
untilStop: leading.untilStop === true || trailing.untilStop === true ? true : undefined,
|
|
234
|
+
budget: leading.budget ?? trailing.budget,
|
|
212
235
|
error: leading.error ?? trailing.error,
|
|
213
236
|
};
|
|
214
237
|
}
|
|
@@ -242,6 +265,7 @@ function runArgs(parsed: CountedRun, prompt: string, plan?: string[]): HordeRunA
|
|
|
242
265
|
if (flags.dashboard) args.dashboard = flags.dashboard;
|
|
243
266
|
if (flags.noVerifier) args.noVerifier = true;
|
|
244
267
|
if (flags.untilStop) args.untilStop = true;
|
|
268
|
+
if (flags.budget !== undefined) args.budget = flags.budget;
|
|
245
269
|
return args;
|
|
246
270
|
}
|
|
247
271
|
|
|
@@ -269,7 +293,9 @@ export function parseHordeArgs(raw: string): HordeArgs {
|
|
|
269
293
|
const leading = takeLaunchFlags(raw);
|
|
270
294
|
if (leading.error) return { kind: "usage", error: leading.error };
|
|
271
295
|
const trimmed = leading.rest;
|
|
272
|
-
const hasFlags =
|
|
296
|
+
const hasFlags =
|
|
297
|
+
leading.dashboard !== undefined || leading.noVerifier === true || leading.untilStop === true ||
|
|
298
|
+
leading.budget !== undefined;
|
|
273
299
|
if (trimmed === "") {
|
|
274
300
|
return hasFlags ? { kind: "usage", error: "count is required" } : { kind: "usage" };
|
|
275
301
|
}
|
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/dashboard-data.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { buildRunGraph } from "./run-graph.js";
|
|
|
26
26
|
import { buildSwarmGraph } from "./swarm-graph.js";
|
|
27
27
|
import type { RunGraph } from "./run-graph.js";
|
|
28
28
|
import { readCleanStop } from "./ledger/run-signals.js";
|
|
29
|
-
import { memberSessionDir, parseMemberLog } from "./member.js";
|
|
29
|
+
import { emptyUsage, memberSessionDir, parseMemberLog, USAGE_KEYS } from "./member.js";
|
|
30
30
|
import type { MemberEndCause, MemberState, MemberStatus, MemberUsage } from "./member.js";
|
|
31
31
|
import { memberActivity, parseReportOutcome, parseTaskMarkdown, statusCounts, totalUsage } from "./prompt.js";
|
|
32
32
|
import type { RunSummaryInput, UsageTotals } from "./prompt.js";
|
|
@@ -72,6 +72,7 @@ export interface LiveSwarm {
|
|
|
72
72
|
runs: LiveRun[];
|
|
73
73
|
partition?: string[] | undefined;
|
|
74
74
|
untilStop?: boolean | undefined;
|
|
75
|
+
budget?: number | undefined;
|
|
75
76
|
/** The swarm step still running after every horde finalized. */
|
|
76
77
|
finalizing?: string[] | undefined;
|
|
77
78
|
deliverable?: string | undefined;
|
|
@@ -383,6 +384,7 @@ interface SwarmRecordOnDisk {
|
|
|
383
384
|
count?: number;
|
|
384
385
|
startedAt?: number;
|
|
385
386
|
untilStop?: boolean;
|
|
387
|
+
budget?: number;
|
|
386
388
|
partition?: string[];
|
|
387
389
|
runs?: Array<{ label?: string; runId?: string; startedAt?: number; endedAt?: number; outcome?: string }>;
|
|
388
390
|
merge?: { startedAt?: number; endedAt?: number; written?: boolean };
|
|
@@ -500,7 +502,7 @@ function liveMemberRow(m: MemberState, now: number, runDir: string): MemberRowVm
|
|
|
500
502
|
paused: m.paused === true,
|
|
501
503
|
turns: m.turns,
|
|
502
504
|
toolCalls: m.toolCalls,
|
|
503
|
-
usage: { ...m.
|
|
505
|
+
usage: { ...m.lifetime },
|
|
504
506
|
elapsedMs: (m.endedAt ?? now) - m.startedAt,
|
|
505
507
|
endCause: m.endCause,
|
|
506
508
|
partial: m.partial === true,
|
|
@@ -521,10 +523,28 @@ function safeSessionStats(filePath: string): SessionStats | undefined {
|
|
|
521
523
|
}
|
|
522
524
|
}
|
|
523
525
|
|
|
526
|
+
/** Stats over every session file of a member (resets and continuations leave one each); oversized files are skipped. */
|
|
527
|
+
function memberSessionStats(runDir: string, name: string): SessionStats | undefined {
|
|
528
|
+
const dir = memberSessionDir(runDir, name);
|
|
529
|
+
const all = listSessionFiles(dir).map((file) => safeSessionStats(path.join(dir, file.name)));
|
|
530
|
+
const parts = all.filter((stats): stats is SessionStats => stats !== undefined);
|
|
531
|
+
if (parts.length === 0) return undefined;
|
|
532
|
+
const total: SessionStats = { entries: 0, turns: 0, toolCalls: 0, usage: emptyUsage() };
|
|
533
|
+
for (const stats of parts) {
|
|
534
|
+
total.entries += stats.entries;
|
|
535
|
+
total.turns += stats.turns;
|
|
536
|
+
total.toolCalls += stats.toolCalls;
|
|
537
|
+
for (const key of USAGE_KEYS) total.usage[key] += stats.usage[key];
|
|
538
|
+
if (stats.first && (!total.first || stats.first < total.first)) total.first = stats.first;
|
|
539
|
+
if (stats.last && (!total.last || stats.last > total.last)) total.last = stats.last;
|
|
540
|
+
}
|
|
541
|
+
return total;
|
|
542
|
+
}
|
|
543
|
+
|
|
524
544
|
function diskMemberRow(runDir: string, name: string): MemberRowVm {
|
|
525
545
|
const log = parseMemberLog(readOptional(path.join(runDir, `${name}.log`)) ?? "");
|
|
526
546
|
const sessionFile = memberSessionFile(runDir, name);
|
|
527
|
-
const stats =
|
|
547
|
+
const stats = memberSessionStats(runDir, name);
|
|
528
548
|
const elapsedMs =
|
|
529
549
|
stats?.first && stats.last ? Date.parse(stats.last) - Date.parse(stats.first) : undefined;
|
|
530
550
|
return {
|
|
@@ -535,7 +555,7 @@ function diskMemberRow(runDir: string, name: string): MemberRowVm {
|
|
|
535
555
|
paused: false,
|
|
536
556
|
turns: log.turns ?? stats?.turns ?? 0,
|
|
537
557
|
toolCalls: log.toolCalls ?? stats?.toolCalls ?? 0,
|
|
538
|
-
usage: stats?.usage ??
|
|
558
|
+
usage: stats?.usage ?? emptyUsage(),
|
|
539
559
|
elapsedMs,
|
|
540
560
|
endCause: log.endCause,
|
|
541
561
|
partial: log.partial,
|
|
@@ -572,6 +592,8 @@ export interface RunOverviewVm {
|
|
|
572
592
|
outcome?: string | undefined;
|
|
573
593
|
stopLabel?: string | undefined;
|
|
574
594
|
cleanStop?: { secondsLeft?: number | undefined; members: string[] } | undefined;
|
|
595
|
+
/** `--budget` in dollars; the header shows `totals.cost` against it. */
|
|
596
|
+
budget?: number | undefined;
|
|
575
597
|
plan?: string[] | undefined;
|
|
576
598
|
canary?: string | undefined;
|
|
577
599
|
canaryError?: string | undefined;
|
|
@@ -617,6 +639,7 @@ function liveOverview(src: DashboardSource, live: LiveRun): RunOverviewVm {
|
|
|
617
639
|
outcome: liveOutcome(live),
|
|
618
640
|
stopLabel: live.stopLabel,
|
|
619
641
|
cleanStop: liveCleanStop(live, now),
|
|
642
|
+
budget: live.budget,
|
|
620
643
|
plan: live.plan,
|
|
621
644
|
canary: live.canary,
|
|
622
645
|
canaryError: live.canaryError,
|
|
@@ -658,6 +681,7 @@ function diskOverview(runId: string, runDir: string): RunOverviewVm {
|
|
|
658
681
|
elapsedMs: elapsed.length > 0 ? Math.max(...elapsed) : undefined,
|
|
659
682
|
outcome: runStateOnDisk(runDir) === "pending" ? "pending" : diskOutcome(runDir),
|
|
660
683
|
cleanStop: cleanStop ? { members: cleanStop.members } : undefined,
|
|
684
|
+
budget: task.budget,
|
|
661
685
|
plan: readOptional(path.join(runDir, "PLAN.md"))
|
|
662
686
|
?.split("\n")
|
|
663
687
|
.filter((line) => line.startsWith("- layer ")),
|
|
@@ -1095,6 +1119,8 @@ export interface SwarmVm {
|
|
|
1095
1119
|
started?: string | undefined;
|
|
1096
1120
|
elapsedMs?: number | undefined;
|
|
1097
1121
|
untilStop: boolean;
|
|
1122
|
+
/** `--budget` in dollars across every horde; the header shows the hordes' summed cost against it. */
|
|
1123
|
+
budget?: number | undefined;
|
|
1098
1124
|
partition: string[];
|
|
1099
1125
|
/** What the merge step is doing or did: "pending", "merging", "merged", "no deliverable". */
|
|
1100
1126
|
merge: string;
|
|
@@ -1175,6 +1201,7 @@ export function loadSwarm(src: DashboardSource, swarmId: string): SwarmVm {
|
|
|
1175
1201
|
started: startedAt !== undefined ? formatDateTime(new Date(startedAt)) : undefined,
|
|
1176
1202
|
elapsedMs: startedAt !== undefined ? (endedAt ?? nowOf(src)) - startedAt : undefined,
|
|
1177
1203
|
untilStop: live?.untilStop === true || record?.untilStop === true,
|
|
1204
|
+
budget: live?.budget ?? record?.budget,
|
|
1178
1205
|
partition,
|
|
1179
1206
|
merge: mergeLabel(live, record, rows),
|
|
1180
1207
|
rows,
|
package/src/dashboard-html.ts
CHANGED
|
@@ -456,6 +456,7 @@ function overviewHeader(vm: RunOverviewVm): string {
|
|
|
456
456
|
const left = vm.cleanStop.secondsLeft === undefined ? "" : `hard stop in ${vm.cleanStop.secondsLeft}s: `;
|
|
457
457
|
rows.push(["clean stop", e(`${left}${who}`)]);
|
|
458
458
|
}
|
|
459
|
+
if (vm.budget !== undefined) rows.push(["budget", `${cost(vm.totals.cost)} of ${cost(vm.budget)} spent`]);
|
|
459
460
|
if (vm.canary) rows.push(["canary", e(vm.canary + (vm.canaryError ? ` (${vm.canaryError})` : ""))]);
|
|
460
461
|
if (vm.diagnosis) rows.push(["diagnosis", e(vm.diagnosis)]);
|
|
461
462
|
if (vm.plan && vm.plan.length > 0) rows.push(["plan", pre(vm.plan.join("\n"))]);
|
|
@@ -981,6 +982,9 @@ function swarmHeader(vm: SwarmVm): string {
|
|
|
981
982
|
["until stop", vm.untilStop ? "yes" : "no"],
|
|
982
983
|
["merge", e(vm.merge)],
|
|
983
984
|
];
|
|
985
|
+
if (vm.budget !== undefined) {
|
|
986
|
+
rows.push(["budget", `${cost(vm.rows.reduce((sum, row) => sum + row.totals.cost, 0))} of ${cost(vm.budget)} spent`]);
|
|
987
|
+
}
|
|
984
988
|
if (vm.notStarted) rows.push(["not started", e(vm.notStarted)]);
|
|
985
989
|
if (vm.partition.length > 0) {
|
|
986
990
|
rows.push(["partition", pre(vm.partition.map((slice, index) => `h${index + 1}: ${slice}`).join("\n"))]);
|
package/src/format.ts
CHANGED
|
@@ -43,6 +43,11 @@ export function formatTokens(n: number): string {
|
|
|
43
43
|
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** A dollar amount for the widget, status and reports, e.g. `$1.20`. */
|
|
47
|
+
export function formatUsd(amount: number): string {
|
|
48
|
+
return `$${amount.toFixed(2)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
export function oneLine(text: string, max: number): string {
|
|
47
52
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
48
53
|
if (collapsed.length <= max) return collapsed;
|
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/member.ts
CHANGED
|
@@ -35,7 +35,10 @@ export interface MemberState {
|
|
|
35
35
|
streaming?: "text" | "thinking" | undefined;
|
|
36
36
|
turns: number;
|
|
37
37
|
toolCalls: number;
|
|
38
|
+
/** Usage of the current session; zeroed by every reset, continuation and repair attempt. */
|
|
38
39
|
usage: MemberUsage;
|
|
40
|
+
/** Usage across every session of this member slot; never reset. Totals and the budget read it. */
|
|
41
|
+
lifetime: MemberUsage;
|
|
39
42
|
lastText: string;
|
|
40
43
|
finalText: string;
|
|
41
44
|
/** In-progress assistant text retained if termination interrupts the final message. */
|
|
@@ -120,6 +123,7 @@ interface SpawnMemberInput {
|
|
|
120
123
|
onFirstOutput?: () => void;
|
|
121
124
|
}
|
|
122
125
|
|
|
126
|
+
export const USAGE_KEYS = ["input", "output", "cacheRead", "cacheWrite", "cost"] as const;
|
|
123
127
|
const LAST_TEXT_CHARS = 80;
|
|
124
128
|
const STDERR_TAIL_LINES = 20;
|
|
125
129
|
const KILL_GRACE_MS = 5_000;
|
|
@@ -254,13 +258,18 @@ export function newMemberState(index: number): MemberState {
|
|
|
254
258
|
status: "starting",
|
|
255
259
|
turns: 0,
|
|
256
260
|
toolCalls: 0,
|
|
257
|
-
usage:
|
|
261
|
+
usage: emptyUsage(),
|
|
262
|
+
lifetime: emptyUsage(),
|
|
258
263
|
lastText: "",
|
|
259
264
|
finalText: "",
|
|
260
265
|
startedAt: Date.now(),
|
|
261
266
|
};
|
|
262
267
|
}
|
|
263
268
|
|
|
269
|
+
export function emptyUsage(): MemberUsage {
|
|
270
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
271
|
+
}
|
|
272
|
+
|
|
264
273
|
/** Reset a member to its initial state so it can be respawned (canary retry, stall reset). */
|
|
265
274
|
export function resetMemberState(member: MemberState): void {
|
|
266
275
|
member.status = "starting";
|
|
@@ -268,7 +277,7 @@ export function resetMemberState(member: MemberState): void {
|
|
|
268
277
|
member.attempt = randomUUID();
|
|
269
278
|
member.turns = 0;
|
|
270
279
|
member.toolCalls = 0;
|
|
271
|
-
member.usage =
|
|
280
|
+
member.usage = emptyUsage();
|
|
272
281
|
member.lastText = "";
|
|
273
282
|
member.finalText = "";
|
|
274
283
|
member.draftText = undefined;
|
|
@@ -419,11 +428,17 @@ function applyAssistantMessageEnd(member: MemberState, message: Record<string, u
|
|
|
419
428
|
member.streaming = undefined;
|
|
420
429
|
const usage = (message.usage ?? {}) as Record<string, unknown>;
|
|
421
430
|
const num = (v: unknown): number => (typeof v === "number" ? v : 0);
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
431
|
+
const delta: MemberUsage = {
|
|
432
|
+
input: num(usage.input),
|
|
433
|
+
output: num(usage.output),
|
|
434
|
+
cacheRead: num(usage.cacheRead),
|
|
435
|
+
cacheWrite: num(usage.cacheWrite),
|
|
436
|
+
cost: num((usage.cost as Record<string, unknown> | undefined)?.total),
|
|
437
|
+
};
|
|
438
|
+
for (const key of USAGE_KEYS) {
|
|
439
|
+
member.usage[key] += delta[key];
|
|
440
|
+
member.lifetime[key] += delta[key];
|
|
441
|
+
}
|
|
427
442
|
const text = messageText(message);
|
|
428
443
|
if (text.trim()) {
|
|
429
444
|
member.finalText = text;
|