@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. 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)) this.adopt(link, msg.result?.thread?.id, tracked === "thread/start");
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
- capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
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,
@@ -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
- this.proc.on("error", (error) => this.fail(error));
279
- this.proc.on("exit", (code) => { if (code !== 0) this.fail(new Error(`pi exited with code ${code}`)); else this.setState("offline"); });
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
  }
@@ -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
+ }