@staix/agent-hub 0.12.19 → 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 +38 -0
- package/README.md +7 -6
- package/docs/agent-notes/adapters.md +10 -2
- package/docs/agent-notes/bus.md +6 -2
- package/docs/agent-notes/daemon.md +10 -3
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +6 -3
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +6 -2
- package/docs/operations.md +655 -34
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +59 -17
- package/docs/smoke.md +137 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +65 -26
- package/src/adapters/acp.ts +67 -6
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +70 -2
- package/src/adapters/local-worker.ts +8 -3
- package/src/adapters/pi.ts +58 -4
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +531 -110
- package/src/cli/console.ts +59 -26
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +130 -0
- package/src/cli/identity.ts +11 -5
- package/src/cli/launch.ts +98 -17
- package/src/cli/main.ts +325 -88
- package/src/cli/preview.ts +14 -3
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +10 -5
- package/src/cli/terminal-recovery.ts +196 -40
- package/src/cli/upgrade-runtime.ts +327 -60
- package/src/cli/upgrade.ts +442 -45
- package/src/hub/ask.ts +1 -1
- package/src/hub/bench.ts +211 -0
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +79 -8
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +54 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +9 -4
- package/src/hub/daemon.ts +401 -48
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +9 -2
- package/src/hub/execution-budget.ts +15 -6
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +136 -1
- package/src/hub/lifecycle.ts +13 -12
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -0
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +68 -8
- package/src/hub/registry.ts +36 -22
- package/src/hub/report.ts +43 -1
- package/src/hub/research.ts +255 -0
- package/src/hub/restart.ts +25 -0
- package/src/hub/routing.ts +48 -16
- package/src/hub/snapshots.ts +3 -2
- package/src/hub/tasks.ts +295 -115
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +2 -1
- package/src/local/tools.ts +14 -10
- package/src/models/mlx.ts +11 -8
- package/src/models/ollama.ts +2 -2
- package/src/models/relay.ts +190 -42
- package/src/models/route/relay-selector.ts +43 -31
- package/src/models/route/runtime.ts +17 -7
- package/src/models/route/signals.ts +12 -1
- package/src/models/route/stage.ts +57 -3
- package/src/omniroute/client.ts +1 -1
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +84 -6
- package/src/ui/task-progress.ts +33 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/routing.toml +8 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import type { Server, ServerWebSocket } from "bun";
|
|
3
3
|
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
4
|
+
import type { PermissionMode } from "../hub/permission-mode.ts";
|
|
4
5
|
import { BasePeer } from "../hub/peers.ts";
|
|
5
6
|
import { codexContext, type ContextReading } from "../hub/context-window.ts";
|
|
6
7
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
@@ -8,6 +9,7 @@ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process
|
|
|
8
9
|
export interface CodexOptions {
|
|
9
10
|
/** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
|
|
10
11
|
proxyPort: number;
|
|
12
|
+
permissionMode?: PermissionMode;
|
|
11
13
|
/** Port for the spawned `codex app-server`. Ignored when `upstreamUrl` is set. */
|
|
12
14
|
appPort: number;
|
|
13
15
|
/** Attach to an already running app-server instead of spawning one (tests). */
|
|
@@ -24,6 +26,8 @@ export interface CodexOptions {
|
|
|
24
26
|
onContext?: (reading: ContextReading) => void;
|
|
25
27
|
/** The native id of each turn as it starts, after the peer turned busy (issue #33: `ahub undo --context`). */
|
|
26
28
|
onTurn?: (turnId: string) => void;
|
|
29
|
+
/** Each thread the TUI starts (`fresh`) or resumes, as it is adopted (#215: which turns belong to which thread). */
|
|
30
|
+
onThread?: (threadId: string, fresh: boolean) => void;
|
|
27
31
|
/**
|
|
28
32
|
* Each completed `fileChange`, `commandExecution`, `mcpToolCall` and `userMessage` item, for turn-free facts (issue
|
|
29
33
|
* #108): the first three are boundaries, a `userMessage` is the readback of a steered fact. Codex emits
|
|
@@ -58,6 +62,10 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
|
|
|
58
62
|
* TUI's, and their responses are swallowed.
|
|
59
63
|
*/
|
|
60
64
|
export class CodexPeer extends BasePeer {
|
|
65
|
+
private permissionMode: PermissionMode;
|
|
66
|
+
private nativeApprovalPolicy: unknown;
|
|
67
|
+
private approvalOverridden = false;
|
|
68
|
+
private readonly restoringApprovals = new Set<string | number>();
|
|
61
69
|
private proc: ChildProcess | undefined;
|
|
62
70
|
private server: Server<Link> | undefined;
|
|
63
71
|
private link: Link | undefined; // the connection that owns the current thread
|
|
@@ -94,6 +102,7 @@ export class CodexPeer extends BasePeer {
|
|
|
94
102
|
private readonly opts: CodexOptions,
|
|
95
103
|
) {
|
|
96
104
|
super(id, opts.watchdogMs);
|
|
105
|
+
this.permissionMode = opts.permissionMode ?? "ask";
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
recoveryMetadata(): Record<string, unknown> {
|
|
@@ -182,6 +191,7 @@ export class CodexPeer extends BasePeer {
|
|
|
182
191
|
this.pending.set(id, {
|
|
183
192
|
deliveryId, kind: "deliver",
|
|
184
193
|
resolve: (result) => {
|
|
194
|
+
if (this.restoringApprovals.delete(id) && this.permissionMode === "ask") this.approvalOverridden = false;
|
|
185
195
|
this.primed = true;
|
|
186
196
|
this.injected = replyParent(envs);
|
|
187
197
|
this.answering = [...envs];
|
|
@@ -194,13 +204,14 @@ export class CodexPeer extends BasePeer {
|
|
|
194
204
|
resolve();
|
|
195
205
|
},
|
|
196
206
|
reject: (e) => {
|
|
207
|
+
this.restoringApprovals.delete(id);
|
|
197
208
|
if (!this.activeTurns.size) this.setState("idle");
|
|
198
209
|
if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
|
|
199
210
|
reject(e);
|
|
200
211
|
},
|
|
201
212
|
});
|
|
202
213
|
link.up.send(
|
|
203
|
-
JSON.stringify({ method: "turn/start", id, params: { threadId: this.threadId, input: [{ type: "text", text }] } }),
|
|
214
|
+
JSON.stringify({ method: "turn/start", id, params: this.applyPermissionPolicy({ threadId: this.threadId, input: [{ type: "text", text }] }, id) }),
|
|
204
215
|
);
|
|
205
216
|
});
|
|
206
217
|
}
|
|
@@ -348,6 +359,7 @@ export class CodexPeer extends BasePeer {
|
|
|
348
359
|
if (this.link !== link) return;
|
|
349
360
|
clearInterval(this.usageTimer);
|
|
350
361
|
this.link = undefined;
|
|
362
|
+
this.restoringApprovals.clear();
|
|
351
363
|
this.threadId = "";
|
|
352
364
|
this.contextEpoch++;
|
|
353
365
|
this.activeTurns.clear();
|
|
@@ -370,8 +382,55 @@ export class CodexPeer extends BasePeer {
|
|
|
370
382
|
this.lastAnswer = "";
|
|
371
383
|
}
|
|
372
384
|
|
|
385
|
+
get proxyAttached(): boolean { return !!this.link && this.link.up.readyState === WebSocket.OPEN; }
|
|
386
|
+
|
|
387
|
+
getPermissionMode(): PermissionMode { return this.permissionMode; }
|
|
388
|
+
|
|
389
|
+
/** Reconcile the daemon's current choice after startup, before the returned proxy URL has a TUI. */
|
|
390
|
+
setStartupPermissionMode(mode: PermissionMode): void {
|
|
391
|
+
if (this.link || this.state !== "offline" || this.approvalOverridden) throw new Error("codex startup mode reconciliation requires a fresh unattached proxy");
|
|
392
|
+
this.permissionMode = mode;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The hub's choice was cleared while no TUI is attached: this proxy sends no override from now on. Returns false
|
|
397
|
+
* when an override it already sent cannot be undone on that thread (its native policy was never reported): a thread
|
|
398
|
+
* started or resumed through the proxy afterwards reports its own policy, and that one is left alone.
|
|
399
|
+
*/
|
|
400
|
+
clearPermissionMode(): boolean {
|
|
401
|
+
const restorable = !this.approvalOverridden || this.nativeApprovalPolicy !== undefined;
|
|
402
|
+
this.permissionMode = "ask";
|
|
403
|
+
return restorable;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async setPermissionMode(mode: PermissionMode): Promise<void> {
|
|
407
|
+
if (!this.link || this.link.up.readyState !== WebSocket.OPEN) throw new Error("codex permission mode unavailable: attach the TUI through ahub codex first");
|
|
408
|
+
if (mode === "ask" && this.approvalOverridden && this.nativeApprovalPolicy === undefined) throw new Error("codex cannot restore ask: native approval policy unavailable; restart the Codex session");
|
|
409
|
+
this.permissionMode = mode;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private applyPermissionPolicy(params: Record<string, unknown>, id: string | number): Record<string, unknown> {
|
|
413
|
+
if (this.permissionMode !== "ask") {
|
|
414
|
+
this.approvalOverridden = true;
|
|
415
|
+
return { ...params, approvalPolicy: this.permissionMode === "never-ask" ? "never" : "on-request" };
|
|
416
|
+
}
|
|
417
|
+
if (this.permissionMode === "ask" && this.approvalOverridden) {
|
|
418
|
+
this.restoringApprovals.add(id);
|
|
419
|
+
return { ...params, approvalPolicy: this.nativeApprovalPolicy };
|
|
420
|
+
}
|
|
421
|
+
return params;
|
|
422
|
+
}
|
|
423
|
+
|
|
373
424
|
private fromTui(link: Link, raw: string): void {
|
|
374
425
|
const msg = parse(raw);
|
|
426
|
+
if (msg?.method === "turn/start" && msg.params && link === this.link) {
|
|
427
|
+
if (msg.params.approvalPolicy != null) this.nativeApprovalPolicy = msg.params.approvalPolicy;
|
|
428
|
+
if (this.permissionMode !== "ask" || this.approvalOverridden) {
|
|
429
|
+
// Native turn overrides persist: ask restores the captured native policy once on successful admission.
|
|
430
|
+
msg.params = this.applyPermissionPolicy(msg.params, msg.id);
|
|
431
|
+
raw = JSON.stringify(msg);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
375
434
|
if (msg?.id !== undefined && TRACKED.has(msg.method)) link.tracked.set(msg.id, msg.method);
|
|
376
435
|
if (link.up.readyState === WebSocket.OPEN) link.up.send(raw);
|
|
377
436
|
else link.backlog.push(raw);
|
|
@@ -389,9 +448,17 @@ export class CodexPeer extends BasePeer {
|
|
|
389
448
|
else p?.resolve(msg.result);
|
|
390
449
|
return; // ours: the TUI never asked for it
|
|
391
450
|
}
|
|
451
|
+
if (msg.id !== undefined && !msg.method && this.restoringApprovals.delete(msg.id) && !msg.error && this.permissionMode === "ask") this.approvalOverridden = false;
|
|
392
452
|
if (typeof msg.result?.userAgent === "string") this.version = /^[^/\s]+\/(\d+\.\d+\.\d+(?:-[\w.]+)?)/.exec(msg.result.userAgent)?.[1] ?? this.version;
|
|
393
453
|
const tracked = msg.id !== undefined && !msg.method ? link.tracked.get(msg.id) : undefined;
|
|
394
|
-
if (tracked && link.tracked.delete(msg.id))
|
|
454
|
+
if (tracked && link.tracked.delete(msg.id)) {
|
|
455
|
+
if (typeof msg.result?.thread?.id === "string") {
|
|
456
|
+
this.nativeApprovalPolicy = msg.result?.approvalPolicy;
|
|
457
|
+
this.approvalOverridden = false;
|
|
458
|
+
this.restoringApprovals.clear();
|
|
459
|
+
this.adopt(link, msg.result.thread.id, tracked === "thread/start");
|
|
460
|
+
}
|
|
461
|
+
}
|
|
395
462
|
else if (msg.method) this.onNotification(link, msg.method, msg.params ?? {});
|
|
396
463
|
link.tui.send(raw);
|
|
397
464
|
}
|
|
@@ -406,6 +473,7 @@ export class CodexPeer extends BasePeer {
|
|
|
406
473
|
this.activeTurns.clear();
|
|
407
474
|
this.forgetTurn();
|
|
408
475
|
this.opts.log?.(`[${this.id}] thread ${threadId}`);
|
|
476
|
+
this.opts.onThread?.(threadId, fresh);
|
|
409
477
|
this.setState("idle");
|
|
410
478
|
this.readUsage();
|
|
411
479
|
clearInterval(this.usageTimer);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { HubRouteRuntime, type RouteEvent, type AdvisorEvent } from "../models/route/runtime.ts";
|
|
2
2
|
import type { HubRoute } from "../models/route/config.ts";
|
|
3
3
|
import type { RouteLabelEvent, RouteTurnOutcome } from "../models/route/labels.ts";
|
|
4
|
+
import type { StaySwitchPolicy } from "../models/route/stage.ts";
|
|
4
5
|
import type { ToolObservation } from "../models/route/signals.ts";
|
|
5
6
|
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
|
|
@@ -24,6 +25,8 @@ export interface LocalOptions {
|
|
|
24
25
|
/** Model id sent straight to OmniRoute when the sidecar is absent, unhealthy or fails a call. */
|
|
25
26
|
fixedModel: string;
|
|
26
27
|
hubRoutes?: () => Record<string, HubRoute>;
|
|
28
|
+
/** routing.toml `stay_switch` for hub stage routes (#197). */
|
|
29
|
+
staySwitch?: () => StaySwitchPolicy | undefined;
|
|
27
30
|
onRoute?: (event: RouteEvent) => void;
|
|
28
31
|
onAdvisor?: (event: AdvisorEvent) => void;
|
|
29
32
|
onRouteOutcome?: (event: RouteLabelEvent) => void;
|
|
@@ -107,6 +110,7 @@ export class LocalPeer extends BasePeer {
|
|
|
107
110
|
return result;
|
|
108
111
|
},
|
|
109
112
|
onCampus: () => this.opts.omni.onCampus(),
|
|
113
|
+
staySwitch: () => this.opts.staySwitch?.(),
|
|
110
114
|
onRoute: event => this.opts.onRoute?.(event),
|
|
111
115
|
onAdvisor: event => this.opts.onAdvisor?.(event),
|
|
112
116
|
onRouteOutcome: event => this.opts.onRouteOutcome?.(event),
|
|
@@ -260,7 +264,8 @@ export class LocalPeer extends BasePeer {
|
|
|
260
264
|
const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
|
|
261
265
|
msgs.push(result); results.push(result);
|
|
262
266
|
this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
|
|
263
|
-
|
|
267
|
+
// Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
|
|
268
|
+
if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
|
|
264
269
|
}
|
|
265
270
|
} finally { if (results.length) this.routes.observeResults(res.routeDecision, res.message, results); }
|
|
266
271
|
}
|
|
@@ -272,13 +277,13 @@ export class LocalPeer extends BasePeer {
|
|
|
272
277
|
return {
|
|
273
278
|
cwd: this.opts.cwd,
|
|
274
279
|
deny: this.opts.tools.deny,
|
|
275
|
-
permit: async (title) => {
|
|
280
|
+
permit: async (title, tool, _signal, path) => {
|
|
276
281
|
if (turnSignal.aborted) return false;
|
|
277
282
|
return new Promise<boolean>((resolve, reject) => {
|
|
278
283
|
const finish = (allowed: boolean) => { turnSignal.removeEventListener("abort", onAbort); resolve(allowed); };
|
|
279
284
|
const onAbort = () => finish(false);
|
|
280
285
|
turnSignal.addEventListener("abort", onAbort, { once: true });
|
|
281
|
-
this.opts.tools.permit(title).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
|
|
286
|
+
this.opts.tools.permit(title, tool, turnSignal, path).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
|
|
282
287
|
});
|
|
283
288
|
},
|
|
284
289
|
sandboxProfile: this.sandboxProfile,
|
package/src/adapters/pi.ts
CHANGED
|
@@ -15,6 +15,16 @@ import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-bu
|
|
|
15
15
|
export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
|
|
16
16
|
export interface PiToolSchema { name: string; description?: string; parameters: Record<string, unknown>; }
|
|
17
17
|
export interface PiRelay { url: string; token: string; models: PiModelDescriptor[]; }
|
|
18
|
+
export interface PiExit {
|
|
19
|
+
cause: "process_exit" | "spawn_error" | "session_shutdown" | "owner_lost" | "owner_stopped";
|
|
20
|
+
code: number | null;
|
|
21
|
+
signal: NodeJS.Signals | null;
|
|
22
|
+
expected: boolean;
|
|
23
|
+
started: boolean;
|
|
24
|
+
turnActive: boolean;
|
|
25
|
+
toolActive: boolean;
|
|
26
|
+
lastToolName?: string;
|
|
27
|
+
}
|
|
18
28
|
export interface PiOptions {
|
|
19
29
|
cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
|
|
20
30
|
model?: string;
|
|
@@ -28,6 +38,8 @@ export interface PiOptions {
|
|
|
28
38
|
/** `ceiling` is the validated, session/turn-bound tool-step ceiling signal (#179), present only when the trusted extension emitted one for this turn. */
|
|
29
39
|
onTurnFailure?: (envs: Envelope[], reason: string, ceiling?: PiToolStepCeiling) => Promise<void>;
|
|
30
40
|
watchdogMs?: number; log?: (line: string) => void;
|
|
41
|
+
/** Once per owner, after it is offline; a TUI owner may have unknown OS exit status. */
|
|
42
|
+
onExit?: (exit: PiExit) => void;
|
|
31
43
|
/** How long stop() waits for a graceful TUI owner exit before verified teardown. Tests shrink this. */
|
|
32
44
|
stopGraceMs?: number;
|
|
33
45
|
}
|
|
@@ -74,6 +86,10 @@ export class PiPeer extends BasePeer {
|
|
|
74
86
|
private ownerClaimed = false;
|
|
75
87
|
private ownerToken = "";
|
|
76
88
|
private stopping = true;
|
|
89
|
+
private exitReported = false;
|
|
90
|
+
private started = false;
|
|
91
|
+
private lastToolName?: string;
|
|
92
|
+
private shutdownExit?: PiExit;
|
|
77
93
|
private ownerPid?: number;
|
|
78
94
|
private ownerSignature?: string;
|
|
79
95
|
private ownerMonitor?: ReturnType<typeof setInterval>;
|
|
@@ -179,6 +195,8 @@ export class PiPeer extends BasePeer {
|
|
|
179
195
|
if (reservation.deadlineAt !== undefined) idleBashTimer = setTimeout(() => idleBashAbort.abort(), Math.max(0, reservation.deadlineAt - Date.now()));
|
|
180
196
|
}
|
|
181
197
|
this.noteActivity();
|
|
198
|
+
const name = String(body.name);
|
|
199
|
+
this.lastToolName = this.opts.tools.some((tool) => tool.name === name) && /^[a-zA-Z0-9_.-]{1,128}$/.test(name) ? name : "unknown";
|
|
182
200
|
this.activeTools++;
|
|
183
201
|
if (this.state === "idle") this.setState("busy");
|
|
184
202
|
if (this.state === "busy") this.touch();
|
|
@@ -230,6 +248,7 @@ export class PiPeer extends BasePeer {
|
|
|
230
248
|
|
|
231
249
|
private async startImpl(): Promise<void> {
|
|
232
250
|
this.stopping = false;
|
|
251
|
+
this.exitReported = false; this.started = false; this.lastToolName = undefined; this.shutdownExit = undefined;
|
|
233
252
|
mkdirSync(this.opts.stateDir, { recursive: true });
|
|
234
253
|
const sessions = join(this.opts.stateDir, "pi-sessions");
|
|
235
254
|
mkdirSync(sessions, { recursive: true, mode: 0o700 });
|
|
@@ -275,8 +294,17 @@ export class PiPeer extends BasePeer {
|
|
|
275
294
|
trackGroup(this.proc);
|
|
276
295
|
this.proc.stdout.on("data", (chunk) => this.onOutput(String(chunk)));
|
|
277
296
|
this.proc.stderr.on("data", (chunk) => this.opts.log?.(`[${this.id}] ${String(chunk).trimEnd()}`));
|
|
278
|
-
|
|
279
|
-
|
|
297
|
+
const proc = this.proc;
|
|
298
|
+
proc.on("error", (error) => {
|
|
299
|
+
const exit = this.exitMetadata("spawn_error");
|
|
300
|
+
this.fail(error);
|
|
301
|
+
this.reportExit(exit);
|
|
302
|
+
});
|
|
303
|
+
proc.on("exit", (code, signal) => {
|
|
304
|
+
const exit = { ...(this.shutdownExit ?? this.exitMetadata("process_exit")), code, signal };
|
|
305
|
+
this.fail(new Error(`pi exited with ${signal ? `signal ${signal}` : `code ${code}`}`));
|
|
306
|
+
this.reportExit(exit);
|
|
307
|
+
});
|
|
280
308
|
const state = await this.waitRpc("get_state", 15_000);
|
|
281
309
|
if (state.success !== true || typeof state.data?.sessionId !== "string" || typeof state.data?.sessionFile !== "string" || !state.data.sessionId || !state.data.sessionFile) throw new Error("Pi get_state did not prove session identity");
|
|
282
310
|
if ((this.opts.sessionId && state.data.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && state.data.sessionFile !== this.opts.sessionFile)) {
|
|
@@ -285,10 +313,13 @@ export class PiPeer extends BasePeer {
|
|
|
285
313
|
this.sessionId = state.data.sessionId; this.sessionFile = state.data.sessionFile;
|
|
286
314
|
this.persistedOnce = existsSync(this.sessionFile);
|
|
287
315
|
if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) throw new Error("Pi startup session identity mismatch");
|
|
316
|
+
this.started = true;
|
|
288
317
|
this.setState("idle");
|
|
289
318
|
}
|
|
290
319
|
|
|
291
320
|
async stop(): Promise<void> {
|
|
321
|
+
const tuiExit = this.opts.mode === "tui" && this.ownerClaimed && this.ownerPid && this.ownerSignature
|
|
322
|
+
? { ...this.exitMetadata("owner_stopped"), expected: true } : undefined;
|
|
292
323
|
for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: "Pi session stopped before settlement" });
|
|
293
324
|
this.activeDeliveryIds.clear();
|
|
294
325
|
this.stopping = true;
|
|
@@ -329,6 +360,7 @@ export class PiPeer extends BasePeer {
|
|
|
329
360
|
this.pending.clear();
|
|
330
361
|
this.server?.stop(true); this.server = undefined; this.setState("offline");
|
|
331
362
|
if (failed) throw failed;
|
|
363
|
+
if (tuiExit) this.reportExit(tuiExit); // the verified owner is gone; never invent an exit for an unclaimed launch
|
|
332
364
|
}
|
|
333
365
|
|
|
334
366
|
/**
|
|
@@ -416,9 +448,15 @@ export class PiPeer extends BasePeer {
|
|
|
416
448
|
this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
|
|
417
449
|
this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
|
|
418
450
|
if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
|
|
419
|
-
this.startOwnerMonitor(); if (this.opts.mode === "tui") this.setState("idle");
|
|
451
|
+
this.startOwnerMonitor(); if (this.opts.mode === "tui") { this.started = true; this.setState("idle"); }
|
|
452
|
+
}
|
|
453
|
+
if (event.type === "session_shutdown") {
|
|
454
|
+
const exit = this.exitMetadata("session_shutdown");
|
|
455
|
+
this.shutdownExit ??= exit; // headless waits for the OS exit, retaining the pre-cleanup turn and stop facts
|
|
456
|
+
this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
|
|
457
|
+
this.fail(new Error("Pi session shut down before settlement"));
|
|
458
|
+
if (this.opts.mode === "tui") this.reportExit(exit);
|
|
420
459
|
}
|
|
421
|
-
if (event.type === "session_shutdown") { this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined; this.fail(new Error("Pi session shut down before settlement")); }
|
|
422
460
|
if (event.type === "agent_start") {
|
|
423
461
|
const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration + 1;
|
|
424
462
|
if (generation <= this.budgetGeneration) return;
|
|
@@ -507,6 +545,20 @@ export class PiPeer extends BasePeer {
|
|
|
507
545
|
try { return await Promise.race([this.sendRpc({ type: command }), new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`Pi ${command} timed out`)), timeout); })]); }
|
|
508
546
|
finally { clearTimeout(timer); }
|
|
509
547
|
}
|
|
548
|
+
private exitMetadata(cause: PiExit["cause"]): PiExit {
|
|
549
|
+
return { cause, code: null, signal: null, expected: this.stopping, started: this.started,
|
|
550
|
+
turnActive: this.agentRunning || this.activeEnvs.length > 0, toolActive: this.activeTools > 0,
|
|
551
|
+
...(this.lastToolName ? { lastToolName: this.lastToolName } : {}) };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private reportExit(exit: PiExit): void {
|
|
555
|
+
if (this.exitReported) return;
|
|
556
|
+
this.exitReported = true;
|
|
557
|
+
const line = `[${this.id}] Pi exit: cause=${exit.cause} code=${exit.code ?? "unknown"} signal=${exit.signal ?? "unknown"} expected=${exit.expected} started=${exit.started} turnActive=${exit.turnActive} toolActive=${exit.toolActive} lastTool=${exit.lastToolName ?? "none"}`;
|
|
558
|
+
try { this.opts.log?.(line); } catch { /* reporting must not prevent owner settlement */ }
|
|
559
|
+
try { this.opts.onExit?.(exit); } catch { /* the owner is already offline */ }
|
|
560
|
+
}
|
|
561
|
+
|
|
510
562
|
private fail(error: Error): void {
|
|
511
563
|
for (const pending of this.pending.values()) pending.reject(error);
|
|
512
564
|
this.pending.clear();
|
|
@@ -525,11 +577,13 @@ export class PiPeer extends BasePeer {
|
|
|
525
577
|
if (this.opts.mode !== "tui" || !this.ownerPid || !this.ownerSignature) return;
|
|
526
578
|
this.ownerMonitor = setInterval(() => {
|
|
527
579
|
if (!this.ownerPid || ownerStillAlive(this.ownerPid, this.ownerSignature)) return;
|
|
580
|
+
const exit = this.exitMetadata("owner_lost");
|
|
528
581
|
this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor();
|
|
529
582
|
this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
|
|
530
583
|
for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner exited"));
|
|
531
584
|
this.tuiCommands.clear(); this.tuiQueue = [];
|
|
532
585
|
this.fail(new Error("Pi owner process exited without session_shutdown"));
|
|
586
|
+
this.reportExit(exit);
|
|
533
587
|
}, 500);
|
|
534
588
|
this.ownerMonitor.unref?.();
|
|
535
589
|
}
|
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
|
+
}
|