@staix/agent-hub 0.12.20 → 0.12.21
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/CHANGELOG.md +22 -0
- package/README.md +6 -5
- package/docs/agent-notes/adapters.md +9 -2
- package/docs/agent-notes/bus.md +5 -2
- package/docs/agent-notes/daemon.md +8 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +2 -1
- package/docs/operations.md +271 -46
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +45 -10
- package/docs/smoke.md +79 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +194 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +68 -10
- package/docs/verified.json +28 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3 -2
- package/src/adapters/acp.ts +67 -6
- package/src/adapters/codex-appserver.ts +67 -2
- package/src/adapters/local-worker.ts +4 -3
- package/src/adapters/pi.ts +58 -4
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +226 -28
- package/src/cli/console.ts +16 -4
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +10 -0
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +96 -17
- package/src/cli/main.ts +159 -18
- package/src/cli/preview.ts +13 -3
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-runtime.ts +67 -18
- package/src/cli/upgrade.ts +81 -9
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +19 -4
- package/src/hub/conductor.ts +22 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +346 -40
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +2 -1
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +21 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +70 -43
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/tools.ts +14 -10
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +83 -6
- package/src/ui/task-progress.ts +33 -0
package/src/cli/bench.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { childEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
6
|
+
import { ControlClient } from "../hub/control-client.ts";
|
|
7
|
+
import { readEvents } from "../hub/events.ts";
|
|
8
|
+
import { taskRecords } from "../hub/research.ts";
|
|
9
|
+
import { appendBench, BENCH_SCHEMA, parseSuite, suiteHash, type Attempt, type AttemptError, type AttemptMetrics, type Fingerprint, type Outcome, type SuiteTask } from "../hub/bench.ts";
|
|
10
|
+
import { hubHome, realPath } from "../hub/project.ts";
|
|
11
|
+
import { processSignature } from "../pi/process-signature.ts";
|
|
12
|
+
import { VERSION } from "../version.ts";
|
|
13
|
+
|
|
14
|
+
/** What the runner needs from the world, so tests can drive it without a terminal. */
|
|
15
|
+
export interface BenchIo {
|
|
16
|
+
/**
|
|
17
|
+
* A shell command in the project, in its own process group, bounded: when the bound passes or `stopped()` turns true
|
|
18
|
+
* the group is stopped, and whatever the command left running in its group is stopped when it exits. Output is never
|
|
19
|
+
* kept. A command that cannot be started reads as a failure (code -1).
|
|
20
|
+
*/
|
|
21
|
+
sh(command: string, cwd: string, timeoutMs: number, stopped?: () => boolean): Promise<{ code: number | null; timedOut: boolean; interrupted: boolean }>;
|
|
22
|
+
connect(): Promise<ControlClient>;
|
|
23
|
+
now(): number;
|
|
24
|
+
sleep(ms: number): Promise<void>;
|
|
25
|
+
log(line: string): void;
|
|
26
|
+
}
|
|
27
|
+
export interface BenchOptions { cwd: string; stateDir: string; suitePath: string; arm: string; repeat: number; only?: string[]; home?: string; pollMs?: number; verifyTimeoutMs?: number; setupTimeoutMs?: number; settleMs?: number; stopped?: () => boolean }
|
|
28
|
+
|
|
29
|
+
const fail = (message: string): never => { throw new Error(message); };
|
|
30
|
+
|
|
31
|
+
/** Commands git runs without a shell; the arguments are ours, never suite text. */
|
|
32
|
+
async function git(cwd: string, args: string[]): Promise<{ code: number; out: string }> {
|
|
33
|
+
const p = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
34
|
+
const [code, out] = await Promise.all([p.exited, new Response(p.stdout).text()]);
|
|
35
|
+
return { code, out: out.trim() };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const defaultIo = (stateDir: string, cwd: string): BenchIo => ({
|
|
39
|
+
sh: async (command, dir, timeoutMs, stopped) => {
|
|
40
|
+
const p = spawn("sh", ["-c", command], { cwd: dir, detached: true, stdio: "ignore", env: childEnv() });
|
|
41
|
+
trackGroup(p);
|
|
42
|
+
let code: number | null | undefined;
|
|
43
|
+
const exited = new Promise<void>((resolve) => {
|
|
44
|
+
p.once("exit", (c) => { code = c; resolve(); });
|
|
45
|
+
p.once("error", () => { code = -1; resolve(); }); // it never started (a missing directory, no shell)
|
|
46
|
+
});
|
|
47
|
+
const end = Date.now() + timeoutMs;
|
|
48
|
+
let timedOut = false, interrupted = false;
|
|
49
|
+
while (code === undefined) {
|
|
50
|
+
if (Date.now() >= end) { timedOut = true; break; }
|
|
51
|
+
if (stopped?.()) { interrupted = true; break; }
|
|
52
|
+
await Promise.race([exited, Bun.sleep(Math.min(200, Math.max(1, end - Date.now())))]);
|
|
53
|
+
}
|
|
54
|
+
// The whole group, also what a finished command left behind in it: nothing may run on into the next attempt's tree.
|
|
55
|
+
await stopOwnedProcess(p, { group: true, killMs: 500 }).catch((error: Error) => {
|
|
56
|
+
// ponytail: only when the stop lists the members the exited leader left (they hold the group id, so it is still
|
|
57
|
+
// ours) is the group killed outright; any other refusal (not provably ours, an unreadable process table) leaves
|
|
58
|
+
// it alone and says so. A leader start-time record at spawn would let stopOwnedProcess sweep it itself.
|
|
59
|
+
if (/still has members \(/.test(error.message)) try { process.kill(-p.pid!, "SIGKILL"); } catch { /* gone */ }
|
|
60
|
+
else console.error(` bench: a command's process group could not be stopped and may still run in the tree (${error.message})`);
|
|
61
|
+
});
|
|
62
|
+
return { code: timedOut || interrupted ? null : code ?? null, timedOut, interrupted };
|
|
63
|
+
},
|
|
64
|
+
connect: () => ControlClient.connect(stateDir, { role: "console", projectRoot: cwd }),
|
|
65
|
+
now: () => Date.now(),
|
|
66
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
67
|
+
log: (line) => console.log(line),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const TREE = ["status", "--porcelain", "--untracked-files=all", "--", ".", ":(exclude,icase).agenthub"];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The preconditions a run checks before it changes anything; each refusal says why. Returns the branch (or commit) to
|
|
74
|
+
* return to. `pinned` receives each suite ref's commit: a reset checks out that commit, never the name, which an
|
|
75
|
+
* agent's own commit could move. `named` receives the full ref name of each one given as a plain branch or tag.
|
|
76
|
+
*/
|
|
77
|
+
export async function benchPreflight(cwd: string, enabled: boolean, refs: string[] = [], pinned: Map<string, string> = new Map(), named: Map<string, string> = new Map()): Promise<string> {
|
|
78
|
+
if (!enabled) throw new Error('benchmarks are off for this project: set "bench": { "enabled": true } in .agenthub/config.json of a project kept for benchmarks (each attempt resets its work tree)');
|
|
79
|
+
const top = await git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
80
|
+
if (top.code !== 0) throw new Error("a benchmark project must be a git repository");
|
|
81
|
+
// A checkout and a clean act on the whole repository: a project in a subdirectory would reset what lies outside it.
|
|
82
|
+
if (realPath(top.out) !== realPath(cwd)) throw new Error(`a benchmark project must be the root of its git repository (${top.out} is), since a reset acts on the whole repository`);
|
|
83
|
+
// The hub's own files must not change under it: a checkout would replace tracked ones, or write them from a ref.
|
|
84
|
+
// Matched without case, since a case-insensitive disk opens `.AGENTHUB/config.json` as `.agenthub/config.json`.
|
|
85
|
+
const tracked = await git(cwd, ["ls-files", "--", ":(icase).agenthub"]);
|
|
86
|
+
if (tracked.code !== 0 || tracked.out) throw new Error("this repository tracks files under .agenthub/, which a reset would replace; in a bench project untrack them (git rm -r --cached .agenthub, then ignore it) and commit");
|
|
87
|
+
for (const ref of refs) {
|
|
88
|
+
const commit = await git(cwd, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${ref}^{commit}`]);
|
|
89
|
+
if (commit.code !== 0 || !/^[0-9a-f]{40,64}$/.test(commit.out)) throw new Error(`suite ref ${ref} is not a commit in this repository`);
|
|
90
|
+
const names = await git(cwd, ["ls-tree", "--name-only", commit.out]);
|
|
91
|
+
if (names.code !== 0 || names.out.split("\n").some((name) => name.toLowerCase() === ".agenthub")) {
|
|
92
|
+
throw new Error(`suite ref ${ref} tracks files under .agenthub/, which a reset to it would write over the hub's own; use a commit made after .agenthub was untracked, or rebuild the ref without that directory`);
|
|
93
|
+
}
|
|
94
|
+
pinned.set(ref, commit.out);
|
|
95
|
+
// A plain branch or tag name has a full ref name; a hash, `HEAD~1` or `main^{commit}` has none and is not tracked.
|
|
96
|
+
// (A name that is both a branch and a tag has none either; it is pinned to what git resolves it to, the tag.)
|
|
97
|
+
// Tracked only when that name is what was pinned: a branch someone named like a hash is not the hash.
|
|
98
|
+
const full = await git(cwd, ["rev-parse", "--symbolic-full-name", "--verify", "--quiet", "--end-of-options", ref]);
|
|
99
|
+
if (full.code === 0 && full.out.startsWith("refs/") && (await git(cwd, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${full.out}^{commit}`])).out === commit.out) named.set(ref, full.out);
|
|
100
|
+
}
|
|
101
|
+
const dirty = await git(cwd, TREE);
|
|
102
|
+
if (dirty.code !== 0 || dirty.out) throw new Error("the work tree has changes; commit or remove them first (a run resets the tree between attempts)");
|
|
103
|
+
// The bare branch name, never `--short`: a branch that shares its name with a tag would come back as `heads/<name>`.
|
|
104
|
+
const branch = await git(cwd, ["symbolic-ref", "--quiet", "HEAD"]);
|
|
105
|
+
return branch.code === 0 && branch.out.startsWith("refs/heads/") ? branch.out.slice("refs/heads/".length) : (await git(cwd, ["rev-parse", "HEAD"])).out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Reset the tree to `target` (a pinned commit, or the branch to return to) and check that nothing is left; each step
|
|
110
|
+
* that fails says so. A pinned commit is checked out as `<hash>^{commit}` and HEAD is compared with it afterwards, so a
|
|
111
|
+
* branch someone named like the hash cannot stand in for it. `-ff` also removes a nested repository an agent created,
|
|
112
|
+
* and only the root `.agenthub` is kept.
|
|
113
|
+
*/
|
|
114
|
+
export async function reset(cwd: string, target: string, detach: boolean, log: (line: string) => void): Promise<boolean> {
|
|
115
|
+
// A branch is named before `--`, so a path of the same name cannot be meant; a commit is peeled, so no branch can.
|
|
116
|
+
if ((await git(cwd, ["checkout", "--quiet", "--force", ...(detach ? ["--detach", `${target}^{commit}`] : [target, "--"])])).code !== 0) { log(` the reset could not check out ${target}`); return false; }
|
|
117
|
+
if (detach && (await git(cwd, ["rev-parse", "HEAD"])).out !== target) { log(` the reset did not land on ${target}: something else in the repository carries that name`); return false; }
|
|
118
|
+
if (!detach && (await git(cwd, ["symbolic-ref", "--quiet", "HEAD"])).out !== `refs/heads/${target}`) { log(` the reset did not return to branch ${target}`); return false; }
|
|
119
|
+
if ((await git(cwd, ["clean", "-ffdxq", "-e", "/.agenthub"])).code !== 0) { log(" the reset could not clean the tree"); return false; }
|
|
120
|
+
const left = await git(cwd, TREE);
|
|
121
|
+
if (left.code === 0 && !left.out) return true;
|
|
122
|
+
const paths = left.out.split("\n").filter(Boolean);
|
|
123
|
+
log(` the reset left ${paths.length || "unreadable"} path${paths.length === 1 ? "" : "s"} behind${paths.length ? `: ${paths.slice(0, 5).join(", ")}${paths.length > 5 ? ", ..." : ""}` : ""}`);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The #247 measures of the attempt's task, the one with this id proposed last (an events file may keep an earlier task
|
|
129
|
+
* with the same id). The turn that approved it ends after the approval and its usage arrives later still, so this waits
|
|
130
|
+
* while a turn started since the task's proposal has not ended and its peer is still busy (`busy`, the hub's live
|
|
131
|
+
* view: a turn the hub closed without an end event does not hold the wait), at most `settleMs`, then one poll more.
|
|
132
|
+
* Unapproved tasks have no record, and a wait that was interrupted, could not read the hub, or ran out with the turn
|
|
133
|
+
* still open gives none either: partial measures would read as complete.
|
|
134
|
+
*/
|
|
135
|
+
export async function metricsOf(opts: Pick<BenchOptions, "stateDir" | "settleMs" | "pollMs" | "stopped">, io: Pick<BenchIo, "now" | "sleep" | "log">, projectId: string, task: number,
|
|
136
|
+
busy: () => Promise<Set<string> | undefined> = async () => undefined): Promise<AttemptMetrics | null> {
|
|
137
|
+
const events = () => readEvents(join(opts.stateDir, "events.jsonl"));
|
|
138
|
+
const last = (all: ReturnType<typeof events>, event: boolean, value: string) =>
|
|
139
|
+
all.flatMap((e) => e.type === "task" && e.id === task && (event ? e.event : e.state) === value ? [e.at] : []).at(-1);
|
|
140
|
+
const until = io.now() + (opts.settleMs ?? 600_000);
|
|
141
|
+
let said = false;
|
|
142
|
+
for (;;) {
|
|
143
|
+
const all = events(), proposed = last(all, true, "proposed"), at = last(all, false, "approved");
|
|
144
|
+
if (!at || !proposed || opts.stopped?.()) break;
|
|
145
|
+
const ended = new Set(all.flatMap((e) => e.type === "turn_end" ? [`${e.peer}\0${e.turn}`] : []));
|
|
146
|
+
const open = new Set(all.flatMap((e) => e.type === "turn_start" && e.at >= proposed && e.at <= at && !ended.has(`${e.peer}\0${e.turn}`) ? [e.peer] : []));
|
|
147
|
+
// `undefined`: no live view was offered, so every open turn is waited for. A view that cannot be read while a
|
|
148
|
+
// turn is open leaves the measures unknown: partial ones would read as complete.
|
|
149
|
+
const working = await busy().then((w) => w, () => null);
|
|
150
|
+
if (working === null && open.size) { io.log(` the hub's status could not be read while task #${task}'s approving turn was open; its measures are not recorded`); return null; }
|
|
151
|
+
const waiting = [...open].filter((peer) => !working || working.has(peer));
|
|
152
|
+
if (!waiting.length) break;
|
|
153
|
+
if (io.now() >= until) { io.log(` ${waiting.join(", ")} still had the turn that approved task #${task} open after the wait; its measures are not recorded`); return null; }
|
|
154
|
+
if (!said) { said = true; io.log(` waiting for ${waiting.join(", ")} to end the turn that approved task #${task}, so its cost is counted`); }
|
|
155
|
+
await io.sleep(opts.pollMs ?? 2000);
|
|
156
|
+
}
|
|
157
|
+
if (opts.stopped?.()) return null; // cut short: partial measures would read as complete
|
|
158
|
+
await io.sleep(opts.pollMs ?? 2000); // the last usage of a turn lands a moment after its end
|
|
159
|
+
if (opts.stopped?.()) return null;
|
|
160
|
+
const all = events();
|
|
161
|
+
const proposed = last(all, true, "proposed");
|
|
162
|
+
const r = taskRecords(all, projectId, { version: VERSION, source: "live" }).find((x) => x.task === task && x.createdAt === proposed);
|
|
163
|
+
return r ? { tokens: r.tokens.total, wallMs: r.wallMs, activeMs: r.activeMs, reviewRounds: r.reviewRounds, changesRequested: r.changesRequested,
|
|
164
|
+
checkFailed: r.checkFailed, firstPass: r.firstPass, turns: r.turns, filesChanged: r.filesChanged, models: r.models } : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* #251: run a suite against the peers attached to this project's hub, one attempt at a time. Each attempt resets the
|
|
169
|
+
* tree to the task's pinned commit, runs its setup, proposes the task as the console does, waits until it is approved
|
|
170
|
+
* or its time is up, then runs its verify command in the tree the agents left. Returns the run id.
|
|
171
|
+
* ponytail: a task that times out stops the run, since an agent may still be working in the tree the next attempt
|
|
172
|
+
* would reset; withdrawing an open task (a board state for it) would let the run continue.
|
|
173
|
+
*/
|
|
174
|
+
export async function runBench(opts: BenchOptions, io: BenchIo): Promise<string> {
|
|
175
|
+
const suiteText = readFileSync(opts.suitePath, "utf8");
|
|
176
|
+
const suite = parseSuite(suiteText);
|
|
177
|
+
// --tasks also sets the order, so a person can randomize it per run (docs/bench.md).
|
|
178
|
+
const tasks = opts.only?.length ? opts.only.map((id) => suite.tasks.find((t) => t.id === id) ?? fail(`--tasks names ${id}, which the suite does not have`)) : suite.tasks;
|
|
179
|
+
const pinned = new Map<string, string>(), named = new Map<string, string>();
|
|
180
|
+
const original = await benchPreflight(opts.cwd, true, [...new Set(tasks.map((t) => t.ref))], pinned, named);
|
|
181
|
+
const onBranch = (await git(opts.cwd, ["symbolic-ref", "--quiet", "HEAD"])).code === 0;
|
|
182
|
+
const hub = await io.connect();
|
|
183
|
+
const run = `${new Date(io.now()).toISOString().slice(0, 10).replace(/-/g, "")}-${randomUUID().slice(0, 8)}`;
|
|
184
|
+
const home = opts.home ?? hubHome();
|
|
185
|
+
try {
|
|
186
|
+
const answered = await hub.request({ t: "status" }, 5000);
|
|
187
|
+
if (!answered.status) throw new Error(`the hub did not answer its status: ${answered.error ?? "no reply"}`);
|
|
188
|
+
const status = answered.status as { projectId: string; version: string; peers: Record<string, { state: string; requestedModel?: string; permissionMode?: string }> };
|
|
189
|
+
const routing = join(opts.cwd, ".agenthub", "routing.toml");
|
|
190
|
+
const fingerprint: Fingerprint = {
|
|
191
|
+
// ponytail: the hub's status reports a requested model for Pi only and permission modes once #242 lands; the rest
|
|
192
|
+
// are recorded as absent. Reading each peer's launch settings would fill them in.
|
|
193
|
+
// The status lists every peer the hub has known; an arm is the ones attached now.
|
|
194
|
+
peers: Object.entries(status.peers ?? {}).filter(([, p]) => p.state !== "offline")
|
|
195
|
+
.map(([id, p]) => ({ id, ...(p.requestedModel ? { model: p.requestedModel } : {}), ...(p.permissionMode ? { permissionMode: p.permissionMode } : {}) })).sort((a, b) => a.id.localeCompare(b.id)),
|
|
196
|
+
routingHash: existsSync(routing) ? createHash("sha256").update(readFileSync(routing)).digest("hex") : null,
|
|
197
|
+
hubVersion: status.version,
|
|
198
|
+
};
|
|
199
|
+
appendBench({ schema: BENCH_SCHEMA, kind: "run", run, suite: suite.name, suiteHash: suiteHash(suiteText), arm: opts.arm, fingerprint, version: VERSION,
|
|
200
|
+
startedAt: new Date(io.now()).toISOString(), tasks: tasks.length, repeats: opts.repeat, order: tasks.map((t) => t.id), runner: { pid: process.pid, signature: processSignature(process.pid) ?? null } }, home);
|
|
201
|
+
io.log(`bench run ${run}: ${suite.name} [${opts.arm}], ${tasks.length} task${tasks.length === 1 ? "" : "s"} x ${opts.repeat}`);
|
|
202
|
+
let stopped: "timeout" | "interrupted" | "error" | undefined;
|
|
203
|
+
outer: for (let repeat = 1; repeat <= opts.repeat; repeat++) {
|
|
204
|
+
for (const task of tasks) {
|
|
205
|
+
if (opts.stopped?.()) { stopped = "interrupted"; break outer; }
|
|
206
|
+
const { attempt, open } = await runAttempt(task, pinned.get(task.ref)!, repeat, run, opts, io, hub, status.projectId);
|
|
207
|
+
appendBench(attempt, home);
|
|
208
|
+
io.log(` ${task.id} #${repeat}: ${attempt.outcome}${attempt.error ? ` (${attempt.error})` : ""}`);
|
|
209
|
+
if (attempt.outcome === "timeout") stopped = "timeout";
|
|
210
|
+
else if (attempt.error === "interrupted") stopped = "interrupted";
|
|
211
|
+
else if (attempt.error === "hub stopped" || attempt.error === "reset failed") stopped = "error";
|
|
212
|
+
if (!stopped) continue;
|
|
213
|
+
if (open) io.log(` task #${attempt.hubTask} is still open on the board; settle it before the next run`);
|
|
214
|
+
break outer;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Back where it started: on its branch, or at its commit. A branch or tag the suite names that moved or went
|
|
218
|
+
// away during the run names different work next time (refs given as a hash or relative to another are not tracked).
|
|
219
|
+
if (!stopped && !(await reset(opts.cwd, original, !onBranch, io.log))) io.log(` the tree was not returned to ${original}; check it before the next run`);
|
|
220
|
+
for (const [ref, full] of named) {
|
|
221
|
+
const commit = pinned.get(ref)!;
|
|
222
|
+
const now = await git(opts.cwd, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${full}^{commit}`]);
|
|
223
|
+
// `rev-parse --verify --quiet` exits 1 for a name that no longer resolves; anything else is git not answering.
|
|
224
|
+
if (now.code === 1) io.log(` suite ref ${ref} was deleted during the run; this run used ${commit.slice(0, 12)}`);
|
|
225
|
+
else if (now.code !== 0) io.log(` suite ref ${ref} could not be checked after the run`);
|
|
226
|
+
else if (now.out !== commit) io.log(` suite ref ${ref} moved during the run (${commit.slice(0, 12)} to ${now.out.slice(0, 12)}); this run used ${commit.slice(0, 12)}`);
|
|
227
|
+
}
|
|
228
|
+
appendBench({ schema: BENCH_SCHEMA, kind: "end", run, endedAt: new Date(io.now()).toISOString(), ...(stopped ? { stopped } : {}) }, home);
|
|
229
|
+
return run;
|
|
230
|
+
} finally { hub.close(); }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One attempt, and whether its hub task is still open on the board when it ends. */
|
|
234
|
+
async function runAttempt(task: SuiteTask, commit: string, repeat: number, run: string, opts: BenchOptions, io: BenchIo, hub: ControlClient, projectId: string): Promise<{ attempt: Attempt; open: boolean }> {
|
|
235
|
+
const started = io.now();
|
|
236
|
+
// The attempt ends when its outcome is known: the wait for its measures is not part of its time.
|
|
237
|
+
const done = (outcome: Outcome, extra: { error?: AttemptError; verifyExit?: number | null; hubTask?: number | null; metrics?: AttemptMetrics | null; ended?: number; open?: boolean } = {}) => {
|
|
238
|
+
const ended = extra.ended ?? io.now();
|
|
239
|
+
const attempt: Attempt = { schema: BENCH_SCHEMA, kind: "attempt", run, task: task.id, repeat, outcome, ...(extra.error ? { error: extra.error } : {}), verifyExit: extra.verifyExit ?? null,
|
|
240
|
+
hubTask: extra.hubTask ?? null, startedAt: new Date(started).toISOString(), endedAt: new Date(ended).toISOString(), ms: ended - started, metrics: extra.metrics ?? null };
|
|
241
|
+
return { attempt, open: extra.open ?? false };
|
|
242
|
+
};
|
|
243
|
+
if (!(await reset(opts.cwd, commit, true, io.log))) return done("error", { error: "reset failed" });
|
|
244
|
+
if (task.setup) {
|
|
245
|
+
const setup = await io.sh(task.setup, opts.cwd, opts.setupTimeoutMs ?? 300_000, opts.stopped);
|
|
246
|
+
if (setup.interrupted) return done("error", { error: "interrupted" });
|
|
247
|
+
if (setup.timedOut) return done("error", { error: "setup timeout" });
|
|
248
|
+
if (setup.code !== 0) return done("error", { error: "setup failed" });
|
|
249
|
+
}
|
|
250
|
+
let id: number;
|
|
251
|
+
try {
|
|
252
|
+
const reply = await hub.request({ t: "task", op: "hub_task_propose", args: { title: task.title, ...(task.detail ? { detail: task.detail } : {}), class: task.class ?? "implement", ...(task.owner ? { owner: task.owner } : {}) } }, 30_000);
|
|
253
|
+
// A request never rejects: a hub that is gone, or silent, answers `ok: false` with one of these.
|
|
254
|
+
if (!reply.ok && /hub connection (is not open|closed)|hub is stopping|no answer from the hub/.test(String(reply.error))) {
|
|
255
|
+
io.log(` ${task.id}: ${reply.error}; if the hub is still up, check its board for a task it may have created`);
|
|
256
|
+
return done("error", { error: "hub stopped" });
|
|
257
|
+
}
|
|
258
|
+
const match = reply.ok ? /^task #(\d+)/.exec(String(reply.text ?? "")) : null;
|
|
259
|
+
if (!match) { io.log(` ${task.id}: the hub refused the task: ${reply.error ?? "no task id in its reply"}`); return done("error", { error: "propose refused" }); }
|
|
260
|
+
id = Number(match[1]);
|
|
261
|
+
} catch { return done("error", { error: "hub stopped" }); }
|
|
262
|
+
const deadline = started + task.timeout_s * 1000;
|
|
263
|
+
for (;;) {
|
|
264
|
+
let state: string | undefined;
|
|
265
|
+
try {
|
|
266
|
+
const list = await hub.request({ t: "task", op: "hub_task_list", args: {} }, 15_000);
|
|
267
|
+
if (!list.ok) return done("error", { error: "hub stopped", hubTask: id, open: true });
|
|
268
|
+
state = (JSON.parse(String(list.text)) as { id: number; state: string }[]).find((t) => t.id === id)?.state;
|
|
269
|
+
} catch { return done("error", { error: "hub stopped", hubTask: id, open: true }); }
|
|
270
|
+
if (state === "approved") break;
|
|
271
|
+
if (opts.stopped?.()) return done("error", { error: "interrupted", hubTask: id, open: true });
|
|
272
|
+
if (io.now() >= deadline) return done("timeout", { hubTask: id, open: true });
|
|
273
|
+
await io.sleep(opts.pollMs ?? 2000);
|
|
274
|
+
}
|
|
275
|
+
const verify = await io.sh(task.verify, opts.cwd, opts.verifyTimeoutMs ?? 600_000, opts.stopped);
|
|
276
|
+
const ended = io.now();
|
|
277
|
+
if (verify.interrupted) return done("error", { error: "interrupted", hubTask: id, ended });
|
|
278
|
+
const busy = async () => {
|
|
279
|
+
const reply = await hub.request({ t: "status" }, 5000);
|
|
280
|
+
if (!reply.status) throw new Error("no status");
|
|
281
|
+
const peers = (reply.status as { peers?: Record<string, { state: string }> }).peers ?? {};
|
|
282
|
+
return new Set(Object.entries(peers).flatMap(([peer, p]) => p.state === "busy" || p.state === "paused" ? [peer] : [])); // a paused peer's turn still runs
|
|
283
|
+
};
|
|
284
|
+
const metrics = await metricsOf(opts, io, projectId, id, busy);
|
|
285
|
+
if (verify.timedOut) return done("fail", { error: "verify timeout", hubTask: id, metrics, ended });
|
|
286
|
+
return done(verify.code === 0 ? "pass" : "fail", { verifyExit: verify.code, hubTask: id, metrics, ended });
|
|
287
|
+
}
|