@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
package/src/hub/bench.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { hubHome } from "./project.ts";
|
|
5
|
+
import { processLiveness } from "../pi/process-signature.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #251: benchmark suites, runs and their comparison. A run's records hold ids, outcomes, exit codes, counts, tokens
|
|
9
|
+
* and times, never the suite's text, a command's output or anything an agent wrote.
|
|
10
|
+
*/
|
|
11
|
+
export const BENCH_SCHEMA = "agent-hub.bench/v1";
|
|
12
|
+
|
|
13
|
+
export interface SuiteTask { id: string; title: string; detail?: string; class?: string; owner?: string; ref: string; setup?: string; verify: string; timeout_s: number }
|
|
14
|
+
export interface Suite { name: string; tasks: SuiteTask[] }
|
|
15
|
+
|
|
16
|
+
const TASK_FIELDS = new Set(["id", "title", "detail", "class", "owner", "ref", "setup", "verify", "timeout_s"]);
|
|
17
|
+
const CLASSES = new Set(["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"]);
|
|
18
|
+
/** A suite file, checked field by field; every refusal names the task and the field. */
|
|
19
|
+
export function parseSuite(text: string): Suite {
|
|
20
|
+
let raw: unknown;
|
|
21
|
+
try { raw = JSON.parse(text); } catch { throw new Error("suite: not valid JSON"); }
|
|
22
|
+
const suite = raw as Record<string, unknown>;
|
|
23
|
+
if (!suite || typeof suite !== "object" || Array.isArray(suite)) throw new Error("suite: expected an object with name and tasks");
|
|
24
|
+
for (const key of Object.keys(suite)) if (key !== "name" && key !== "tasks") throw new Error(`suite: unknown field ${key}`);
|
|
25
|
+
if (typeof suite.name !== "string" || !suite.name.trim()) throw new Error("suite: name is required");
|
|
26
|
+
if (!Array.isArray(suite.tasks) || !suite.tasks.length) throw new Error("suite: tasks must be a non-empty list");
|
|
27
|
+
const ids = new Set<string>();
|
|
28
|
+
const tasks = suite.tasks.map((item, i): SuiteTask => {
|
|
29
|
+
const t = item as Record<string, unknown>;
|
|
30
|
+
const where = `suite task ${typeof t?.id === "string" ? t.id : `#${i + 1}`}`;
|
|
31
|
+
if (!t || typeof t !== "object" || Array.isArray(t)) throw new Error(`${where}: expected an object`);
|
|
32
|
+
for (const key of Object.keys(t)) if (!TASK_FIELDS.has(key)) throw new Error(`${where}: unknown field ${key}`);
|
|
33
|
+
for (const key of ["id", "title", "ref", "verify"]) if (typeof t[key] !== "string" || !(t[key] as string).trim()) throw new Error(`${where}: ${key} is required`);
|
|
34
|
+
for (const key of ["detail", "class", "owner", "setup"]) if (t[key] !== undefined && typeof t[key] !== "string") throw new Error(`${where}: ${key} must be text`);
|
|
35
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(t.id as string)) throw new Error(`${where}: id may hold letters, digits, dot, dash and underscore`);
|
|
36
|
+
if (ids.has(t.id as string)) throw new Error(`${where}: duplicate id`);
|
|
37
|
+
ids.add(t.id as string);
|
|
38
|
+
if (t.class !== undefined && !CLASSES.has(t.class as string)) throw new Error(`${where}: class must be one of ${[...CLASSES].join(", ")}`);
|
|
39
|
+
if (typeof t.timeout_s !== "number" || !Number.isFinite(t.timeout_s) || t.timeout_s < 10 || t.timeout_s > 86_400) throw new Error(`${where}: timeout_s must be a number of seconds from 10 to 86400`);
|
|
40
|
+
return t as unknown as SuiteTask;
|
|
41
|
+
});
|
|
42
|
+
return { name: suite.name, tasks };
|
|
43
|
+
}
|
|
44
|
+
export const suiteHash = (text: string): string => createHash("sha256").update(text).digest("hex");
|
|
45
|
+
|
|
46
|
+
export interface Fingerprint { peers: { id: string; model?: string; permissionMode?: string }[]; routingHash: string | null; hubVersion: string }
|
|
47
|
+
export interface RunHeader {
|
|
48
|
+
schema: typeof BENCH_SCHEMA; kind: "run"; run: string; suite: string; suiteHash: string; arm: string; fingerprint: Fingerprint;
|
|
49
|
+
version: string; startedAt: string; tasks: number; repeats: number; runner: { pid: number; signature: string | null };
|
|
50
|
+
/** Task ids in run order, so a running run can name the attempt in progress. */
|
|
51
|
+
order: string[];
|
|
52
|
+
}
|
|
53
|
+
export type Outcome = "pass" | "fail" | "timeout" | "error";
|
|
54
|
+
/** Closed list: a reason never quotes a command or its output. */
|
|
55
|
+
export type AttemptError = "setup failed" | "setup timeout" | "propose refused" | "hub stopped" | "verify timeout" | "reset failed" | "interrupted";
|
|
56
|
+
export interface AttemptMetrics {
|
|
57
|
+
tokens: number; wallMs: number | null; activeMs: number; reviewRounds: number; changesRequested: number; checkFailed: number;
|
|
58
|
+
firstPass: boolean; turns: number; filesChanged: number; models: string[];
|
|
59
|
+
}
|
|
60
|
+
export interface Attempt {
|
|
61
|
+
schema: typeof BENCH_SCHEMA; kind: "attempt"; run: string; task: string; repeat: number; outcome: Outcome; error?: AttemptError;
|
|
62
|
+
verifyExit: number | null; hubTask: number | null; startedAt: string; endedAt: string; ms: number; metrics: AttemptMetrics | null;
|
|
63
|
+
}
|
|
64
|
+
export interface RunEnd { schema: typeof BENCH_SCHEMA; kind: "end"; run: string; endedAt: string; stopped?: "timeout" | "interrupted" | "error" }
|
|
65
|
+
export type BenchRecord = RunHeader | Attempt | RunEnd;
|
|
66
|
+
export interface BenchRun { header: RunHeader; attempts: Attempt[]; end?: RunEnd; state: "running" | "finished" | "stopped" | "interrupted" | "unknown" }
|
|
67
|
+
|
|
68
|
+
export const benchDir = (home = hubHome()): string => join(home, "bench");
|
|
69
|
+
export const runFile = (run: string, home = hubHome()): string => join(benchDir(home), `${run}.jsonl`);
|
|
70
|
+
export function appendBench(record: BenchRecord, home = hubHome()): void {
|
|
71
|
+
mkdirSync(benchDir(home), { recursive: true, mode: 0o700 });
|
|
72
|
+
chmodSync(benchDir(home), 0o700); // mkdir's mode applies only when it creates the directory
|
|
73
|
+
const file = runFile(record.run, home);
|
|
74
|
+
appendFileSync(file, JSON.stringify(record) + "\n", { mode: 0o600 });
|
|
75
|
+
chmodSync(file, 0o600);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every run on this machine, newest first. A run with no end record is running while its runner lives. */
|
|
79
|
+
export function readRuns(home = hubHome()): BenchRun[] {
|
|
80
|
+
const dir = benchDir(home);
|
|
81
|
+
if (!existsSync(dir)) return [];
|
|
82
|
+
const runs: BenchRun[] = [];
|
|
83
|
+
for (const name of readdirSync(dir).filter((n) => n.endsWith(".jsonl"))) try {
|
|
84
|
+
let header: RunHeader | undefined, end: RunEnd | undefined;
|
|
85
|
+
const attempts: Attempt[] = [];
|
|
86
|
+
for (const line of readFileSync(join(dir, name), "utf8").split("\n")) {
|
|
87
|
+
if (!line.trim()) continue;
|
|
88
|
+
let r: BenchRecord;
|
|
89
|
+
try { r = JSON.parse(line); } catch { continue; }
|
|
90
|
+
if (r?.schema !== BENCH_SCHEMA) continue;
|
|
91
|
+
if (r.kind === "run") header = r; else if (r.kind === "attempt") attempts.push(r); else if (r.kind === "end") end = r;
|
|
92
|
+
}
|
|
93
|
+
if (!header || typeof header.startedAt !== "string" || !header.runner) continue;
|
|
94
|
+
const owner = end ? undefined : processLiveness(header.runner.pid, header.runner.signature);
|
|
95
|
+
const state = end ? (end.stopped ? "stopped" : "finished") : owner === "live" ? "running" : owner === "gone" ? "interrupted" : "unknown";
|
|
96
|
+
runs.push({ header, attempts, ...(end ? { end } : {}), state });
|
|
97
|
+
} catch { /* a file that cannot be read or has no valid header is not a run; the others still are */ }
|
|
98
|
+
return runs.sort((a, b) => b.header.startedAt.localeCompare(a.header.startedAt));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const median = (values: number[]): number | null => {
|
|
102
|
+
if (!values.length) return null;
|
|
103
|
+
const s = [...values].sort((a, b) => a - b), m = Math.floor(s.length / 2);
|
|
104
|
+
return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
|
|
105
|
+
};
|
|
106
|
+
export interface Measures {
|
|
107
|
+
attempts: number; pass: number; fail: number; timeout: number; error: number;
|
|
108
|
+
/** What the measures are computed over: attempts that did not end in an error; attempts that have #247 measures. */
|
|
109
|
+
scored: number; measured: number;
|
|
110
|
+
passRate: number | null; firstPassRate: number | null; tokensMedian: number | null; wallMsMedian: number | null; reworkMean: number | null;
|
|
111
|
+
}
|
|
112
|
+
export function measures(attempts: Attempt[]): Measures {
|
|
113
|
+
const count = (o: Outcome) => attempts.filter((a) => a.outcome === o).length;
|
|
114
|
+
const scored = attempts.filter((a) => a.outcome !== "error");
|
|
115
|
+
const withMetrics = attempts.flatMap((a) => a.metrics ? [a.metrics] : []);
|
|
116
|
+
return { attempts: attempts.length, pass: count("pass"), fail: count("fail"), timeout: count("timeout"), error: count("error"), scored: scored.length, measured: withMetrics.length,
|
|
117
|
+
passRate: scored.length ? count("pass") / scored.length : null,
|
|
118
|
+
firstPassRate: withMetrics.length ? withMetrics.filter((m) => m.firstPass).length / withMetrics.length : null,
|
|
119
|
+
tokensMedian: median(withMetrics.map((m) => m.tokens)), wallMsMedian: median(scored.map((a) => a.ms)),
|
|
120
|
+
reworkMean: withMetrics.length ? withMetrics.reduce((n, m) => n + m.changesRequested, 0) / withMetrics.length : null };
|
|
121
|
+
}
|
|
122
|
+
export interface RunSummary extends Measures { run: string; suite: string; arm: string; state: BenchRun["state"]; startedAt: string; done: number; total: number; current?: string }
|
|
123
|
+
export function runSummary(r: BenchRun): RunSummary {
|
|
124
|
+
const total = r.header.tasks * r.header.repeats;
|
|
125
|
+
const current = r.state === "running" && r.attempts.length < total ? r.header.order?.[r.attempts.length % r.header.order.length] : undefined;
|
|
126
|
+
return { run: r.header.run, suite: r.header.suite, arm: r.header.arm, state: r.state, startedAt: r.header.startedAt, done: r.attempts.length, total, ...(current ? { current } : {}), ...measures(r.attempts) };
|
|
127
|
+
}
|
|
128
|
+
/** Per task and overall for one run (or several runs of one arm). */
|
|
129
|
+
export function benchReport(runs: BenchRun[]): { overall: Measures; tasks: Record<string, Measures> } {
|
|
130
|
+
const attempts = runs.flatMap((r) => r.attempts);
|
|
131
|
+
// A Map: a task id may be `constructor` or `__proto__`, which a plain object answers with an inherited value.
|
|
132
|
+
const tasks = new Map<string, Attempt[]>();
|
|
133
|
+
for (const a of attempts) { const id = String(a.task); tasks.set(id, [...(tasks.get(id) ?? []), a]); } // a store line is not trusted to hold a string
|
|
134
|
+
return { overall: measures(attempts), tasks: Object.fromEntries([...tasks].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, measures(v)])) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A seeded generator, so a comparison prints the same interval every time for the same records. */
|
|
138
|
+
function mulberry32(seed: number): () => number {
|
|
139
|
+
let a = seed >>> 0;
|
|
140
|
+
return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
141
|
+
}
|
|
142
|
+
export const MIN_ATTEMPTS = 5;
|
|
143
|
+
type MeasureName = "passRate" | "firstPassRate" | "tokensMedian" | "wallMsMedian";
|
|
144
|
+
export interface Comparison {
|
|
145
|
+
arms: { arm: string; runs: number; measures: Measures }[];
|
|
146
|
+
/** Each other arm against the first: the difference and its 95% bootstrap interval; inconclusive below MIN_ATTEMPTS. */
|
|
147
|
+
differences: { arm: string; measure: MeasureName; diff: number | null; low: number | null; high: number | null; inconclusive: boolean }[];
|
|
148
|
+
}
|
|
149
|
+
export function benchCompare(groups: { arm: string; runs: BenchRun[] }[], resamples = 1000, seed = 251): Comparison {
|
|
150
|
+
const arms = groups.map((g) => ({ arm: g.arm, runs: g.runs.length, attempts: g.runs.flatMap((r) => r.attempts) }));
|
|
151
|
+
const base = arms[0];
|
|
152
|
+
const differences: Comparison["differences"] = [];
|
|
153
|
+
const names: MeasureName[] = ["passRate", "firstPassRate", "tokensMedian", "wallMsMedian"];
|
|
154
|
+
if (base) for (const other of arms.slice(1)) {
|
|
155
|
+
const random = mulberry32(seed);
|
|
156
|
+
const pick = (xs: Attempt[]) => Array.from({ length: xs.length }, () => xs[Math.floor(random() * xs.length)]!);
|
|
157
|
+
const samples: Record<MeasureName, number[]> = { passRate: [], firstPassRate: [], tokensMedian: [], wallMsMedian: [] };
|
|
158
|
+
if (base.attempts.length && other.attempts.length) for (let i = 0; i < resamples; i++) {
|
|
159
|
+
const a = measures(pick(base.attempts)), b = measures(pick(other.attempts));
|
|
160
|
+
for (const n of names) if (a[n] !== null && b[n] !== null) samples[n].push(b[n]! - a[n]!);
|
|
161
|
+
}
|
|
162
|
+
const ma = measures(base.attempts), mb = measures(other.attempts);
|
|
163
|
+
// What each measure is computed over: pass rate and wall time over attempts that did not end in an error, the
|
|
164
|
+
// #247 measures over attempts that have them. Below MIN_ATTEMPTS of those in either arm a difference is inconclusive.
|
|
165
|
+
const counted = (xs: Attempt[], n: MeasureName) => n === "passRate" || n === "wallMsMedian" ? xs.filter((a) => a.outcome !== "error").length : xs.filter((a) => a.metrics).length;
|
|
166
|
+
for (const n of names) {
|
|
167
|
+
const s = samples[n].sort((x, y) => x - y);
|
|
168
|
+
const diff = ma[n] !== null && mb[n] !== null ? mb[n]! - ma[n]! : null;
|
|
169
|
+
const rank = (q: number) => s[Math.min(s.length - 1, Math.max(0, Math.ceil(q * s.length) - 1))]!; // nearest rank
|
|
170
|
+
differences.push({ arm: other.arm, measure: n, diff, low: s.length ? rank(0.025) : null, high: s.length ? rank(0.975) : null,
|
|
171
|
+
inconclusive: counted(base.attempts, n) < MIN_ATTEMPTS || counted(other.attempts, n) < MIN_ATTEMPTS || diff === null });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { arms: arms.map((a) => ({ arm: a.arm, runs: a.runs, measures: measures(a.attempts) })), differences };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The runs an arm label stands for: every run of it that is over, a stopped one included (its attempts carry their own outcomes). */
|
|
178
|
+
export const armRuns = (runs: BenchRun[], arm: string): BenchRun[] => runs.filter((r) => r.header.arm === arm && r.state !== "running" && r.state !== "unknown");
|
|
179
|
+
/** Groups to compare must run the same suite (name and file hash), or their numbers measure different work. */
|
|
180
|
+
export function sameSuite(groups: { arm: string; runs: BenchRun[] }[]): string | null {
|
|
181
|
+
const keys = new Set(groups.flatMap((g) => g.runs.map((r) => `${r.header.suite} (${String(r.header.suiteHash ?? "").slice(0, 12)})`)));
|
|
182
|
+
return keys.size > 1 ? `these runs come from different suites or suite versions: ${[...keys].join(", ")}` : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const pct = (v: number | null) => v === null ? "-" : `${Math.round(v * 100)}%`;
|
|
186
|
+
const num = (v: number | null) => v === null ? "-" : String(Math.round(v));
|
|
187
|
+
const dur = (ms: number | null) => ms === null ? "-" : ms < 120_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`;
|
|
188
|
+
const row = (name: string, m: Measures) => `${name.padEnd(28)} ${String(m.attempts).padStart(8)} ${pct(m.passRate).padStart(5)} ${`${m.pass}/${m.fail}/${m.timeout}/${m.error}`.padStart(11)} ${pct(m.firstPassRate).padStart(10)} ${(m.reworkMean === null ? "-" : m.reworkMean.toFixed(2)).padStart(6)} ${num(m.tokensMedian).padStart(10)} ${dur(m.wallMsMedian).padStart(8)}`;
|
|
189
|
+
const HEAD = `${"".padEnd(28)} ${"attempts".padStart(8)} ${"pass".padStart(5)} ${"p/f/t/e".padStart(11)} ${"first pass".padStart(10)} ${"rework".padStart(6)} ${"tokens p50".padStart(10)} ${"wall p50".padStart(8)}`;
|
|
190
|
+
export function formatRuns(runs: BenchRun[]): string[] {
|
|
191
|
+
if (!runs.length) return ["no benchmark runs on this machine (ahub bench run <suite.json> --arm <label>)"];
|
|
192
|
+
return runs.map(runSummary).map((s) => `${s.run} ${s.state.padEnd(11)} ${s.suite} [${s.arm}] ${s.done}/${s.total} pass ${pct(s.passRate)} tokens p50 ${num(s.tokensMedian)} wall p50 ${dur(s.wallMsMedian)} ${s.startedAt}`);
|
|
193
|
+
}
|
|
194
|
+
export function formatReport(r: ReturnType<typeof benchReport>): string[] {
|
|
195
|
+
return [HEAD, row("all tasks", r.overall), ...Object.entries(r.tasks).map(([k, m]) => row(k, m))];
|
|
196
|
+
}
|
|
197
|
+
export function formatCompare(c: Comparison): string[] {
|
|
198
|
+
const lines = [HEAD, ...c.arms.map((a) => row(`${a.arm} (${a.runs} run${a.runs === 1 ? "" : "s"})`, a.measures))];
|
|
199
|
+
const fmt = (n: MeasureName, v: number | null) => v === null ? "-" : n.endsWith("Rate") ? `${v >= 0 ? "+" : ""}${Math.round(v * 100)}pt` : n === "wallMsMedian" ? `${v >= 0 ? "+" : "-"}${dur(Math.abs(v))}` : `${v >= 0 ? "+" : ""}${Math.round(v)}`;
|
|
200
|
+
for (const d of c.differences) lines.push(`${d.arm} vs ${c.arms[0]!.arm}: ${d.measure} ${fmt(d.measure, d.diff)} (95% ${fmt(d.measure, d.low)} to ${fmt(d.measure, d.high)})${d.inconclusive ? `, inconclusive: fewer than ${MIN_ATTEMPTS} attempts counted for it in an arm` : ""}`);
|
|
201
|
+
return lines;
|
|
202
|
+
}
|
|
203
|
+
export const CSV_COLUMNS = ["run", "suite", "arm", "task", "repeat", "outcome", "error", "verifyExit", "hubTask", "startedAt", "endedAt", "ms", "tokens", "wallMs", "activeMs",
|
|
204
|
+
"reviewRounds", "changesRequested", "checkFailed", "firstPass", "turns", "filesChanged", "models"] as const;
|
|
205
|
+
export function benchCsv(runs: BenchRun[]): string {
|
|
206
|
+
const cell = (v: unknown) => { const s = v === null || v === undefined ? "" : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
|
|
207
|
+
const rows = runs.flatMap((r) => r.attempts.map((a) => [r.header.run, r.header.suite, r.header.arm, a.task, a.repeat, a.outcome, a.error, a.verifyExit, a.hubTask, a.startedAt, a.endedAt, a.ms,
|
|
208
|
+
a.metrics?.tokens, a.metrics?.wallMs, a.metrics?.activeMs, a.metrics?.reviewRounds, a.metrics?.changesRequested, a.metrics?.checkFailed, a.metrics?.firstPass, a.metrics?.turns,
|
|
209
|
+
a.metrics?.filesChanged, a.metrics?.models.join(" ")].map(cell).join(",")));
|
|
210
|
+
return [CSV_COLUMNS.join(","), ...rows].join("\n") + "\n";
|
|
211
|
+
}
|
package/src/hub/board.ts
CHANGED
|
@@ -27,12 +27,16 @@ export interface HistoryEntry {
|
|
|
27
27
|
note?: string;
|
|
28
28
|
/** The owner an event that set it left the task with (#67); absent in rows written before 0.11. */
|
|
29
29
|
owner?: PeerId | null;
|
|
30
|
+
/** On a decline or an escalation: the owner it was declined for or taken from (#207); later reroutes leave it out. */
|
|
31
|
+
from?: PeerId;
|
|
30
32
|
/** On a hand-over: the new owner's split profile then (#109); absent when it was unknown. */
|
|
31
33
|
profile?: string;
|
|
32
34
|
/** Structured cause recorded at the operation source, never inferred from note text. */
|
|
33
35
|
reason?: TaskMoveReason;
|
|
34
36
|
/** Persisted task-idle ladder; never counted as fresh task activity (#186). */
|
|
35
37
|
sweep?: SweepRecord;
|
|
38
|
+
/** The PII screen did not clear this entry's note (#198): only the console reads it. */
|
|
39
|
+
withheld?: true;
|
|
36
40
|
}
|
|
37
41
|
export interface Task {
|
|
38
42
|
id: number;
|
|
@@ -47,6 +51,8 @@ export interface Task {
|
|
|
47
51
|
plan?: TaskPlan;
|
|
48
52
|
/** Tasks that must be approved before this one is offered (issue #34); fixed when it is proposed. */
|
|
49
53
|
deps?: number[];
|
|
54
|
+
/** Who routing offers the task first once nothing is left to wait for (issue #207); null when nobody was named. */
|
|
55
|
+
reserved?: PeerId | null;
|
|
50
56
|
signals: string[];
|
|
51
57
|
/** consecutive changes_requested verdicts */
|
|
52
58
|
rejections: number;
|
|
@@ -95,14 +101,16 @@ export class Board {
|
|
|
95
101
|
for (const [col, empty] of [["plan", "{}"], ["deps", "[]"]] as const) {
|
|
96
102
|
if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN ${col} TEXT NOT NULL DEFAULT '${empty}'`);
|
|
97
103
|
}
|
|
104
|
+
// And from before issue #207: existing rows reserve nobody.
|
|
105
|
+
if (!have.has("reserved")) this.db.run("ALTER TABLE tasks ADD COLUMN reserved TEXT");
|
|
98
106
|
}
|
|
99
107
|
|
|
100
|
-
propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; signals?: string[] }): Task {
|
|
108
|
+
propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; reserved?: PeerId; signals?: string[] }): Task {
|
|
101
109
|
const now = Date.now();
|
|
102
110
|
const history: HistoryEntry[] = [{ at: now, by, event: "proposed" }];
|
|
103
111
|
const { lastInsertRowid } = this.db
|
|
104
|
-
.query("INSERT INTO tasks (title, detail, class, state, refs, plan, deps, signals, history, created, updated) VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?, ?, ?)")
|
|
105
|
-
.run(t.title, t.detail ?? "", t.class, JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now);
|
|
112
|
+
.query("INSERT INTO tasks (title, detail, class, state, refs, plan, deps, reserved, signals, history, created, updated) VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
113
|
+
.run(t.title, t.detail ?? "", t.class, JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), t.reserved ?? null, JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now);
|
|
106
114
|
const task = this.get(Number(lastInsertRowid))!;
|
|
107
115
|
this.changed(task, history[0]!);
|
|
108
116
|
return task;
|
|
@@ -126,17 +134,17 @@ export class Board {
|
|
|
126
134
|
|
|
127
135
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
128
136
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
129
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "refs" | "plan" | "rejections">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason"> = {}): Task {
|
|
137
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld"> = {}): Task {
|
|
130
138
|
const task = this.get(id);
|
|
131
139
|
if (!task) throw new Error(`no task #${id}`);
|
|
132
140
|
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state)) {
|
|
133
141
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
134
142
|
}
|
|
135
143
|
const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
|
|
136
|
-
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}) }];
|
|
144
|
+
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}) }];
|
|
137
145
|
this.db
|
|
138
|
-
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, refs = ?, plan = ?, rejections = ?, history = ?, updated = ? WHERE id = ?")
|
|
139
|
-
.run(next.state, next.owner, next.reviewer, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(history), Date.now(), id);
|
|
146
|
+
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, updated = ? WHERE id = ?")
|
|
147
|
+
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), Date.now(), id);
|
|
140
148
|
const updated = this.get(id)!;
|
|
141
149
|
this.changed(updated, history.at(-1)!);
|
|
142
150
|
return updated;
|
package/src/hub/bus.ts
CHANGED
|
@@ -295,7 +295,14 @@ export class Bus {
|
|
|
295
295
|
if (current.revision === 0) {
|
|
296
296
|
this.journal.importSnapshot(snapshot.journal ?? { schemaVersion: 1, revision: 1, instanceId: this.journal.instanceId, bus: snapshot, deliveries: [], manualPaused: snapshot.manualPaused ?? [] }, _operationId);
|
|
297
297
|
}
|
|
298
|
+
const committedQueues = snapshot.queues;
|
|
298
299
|
snapshot = this.journal.snapshot().bus;
|
|
300
|
+
// Offline peers need not reattach. Their empty queue keys still belong to the
|
|
301
|
+
// committed integrity view, even when no delivery ever persisted those keys (#234).
|
|
302
|
+
// The journal remains authoritative for work: never resurrect a non-empty queue.
|
|
303
|
+
for (const [id, queue] of Object.entries(committedQueues)) {
|
|
304
|
+
if (queue.length === 0 && !Object.hasOwn(snapshot.queues, id)) snapshot.queues[id] = [];
|
|
305
|
+
}
|
|
299
306
|
}
|
|
300
307
|
this.loadSnapshot(snapshot);
|
|
301
308
|
this.persist();
|
|
@@ -348,7 +355,10 @@ export class Bus {
|
|
|
348
355
|
if (record.revision !== revision) throw new Error("stale delivery revision");
|
|
349
356
|
if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
|
|
350
357
|
if (action === "retry" && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
|
|
351
|
-
const
|
|
358
|
+
const queue = this.queues.get(record.peer) ?? [];
|
|
359
|
+
const hadQueue = this.queues.has(record.peer);
|
|
360
|
+
const before = [...queue];
|
|
361
|
+
const preface = this.prefaces.get(record.peer);
|
|
352
362
|
try {
|
|
353
363
|
this.journal.transaction(() => {
|
|
354
364
|
if (!this.journal!.get(id)) this.journal!.createDelivery({ ...record });
|
|
@@ -356,10 +366,9 @@ export class Bus {
|
|
|
356
366
|
this.journal!.resolve(id, current.revision, action, reason, revision);
|
|
357
367
|
if (record.state === "queued") {
|
|
358
368
|
const ids = new Set(record.originals.map((env) => env.id));
|
|
359
|
-
|
|
369
|
+
queue.splice(0, queue.length, ...queue.filter((env) => !ids.has(env.id)));
|
|
360
370
|
}
|
|
361
371
|
if (action === "retry") {
|
|
362
|
-
const queue = this.queues.get(record.peer) ?? [];
|
|
363
372
|
for (const env of record.originals) {
|
|
364
373
|
if (env.from === HUB && env.kind === "presence") this.restorePreface(record.peer, env);
|
|
365
374
|
else if (!queue.some((old) => old.id === env.id)) queue.push(env);
|
|
@@ -368,7 +377,13 @@ export class Bus {
|
|
|
368
377
|
}
|
|
369
378
|
this.persist();
|
|
370
379
|
});
|
|
371
|
-
} catch (error) {
|
|
380
|
+
} catch (error) {
|
|
381
|
+
queue.splice(0, queue.length, ...before);
|
|
382
|
+
if (!hadQueue) this.queues.delete(record.peer);
|
|
383
|
+
if (preface) this.prefaces.set(record.peer, preface);
|
|
384
|
+
else this.prefaces.delete(record.peer);
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
372
387
|
this.refreshRecoveryHold(record.peer);
|
|
373
388
|
this.onQueues?.();
|
|
374
389
|
void this.drain(record.peer);
|
|
@@ -715,13 +730,69 @@ export class Bus {
|
|
|
715
730
|
finally { this.steering--; this.onQueues?.(); }
|
|
716
731
|
}
|
|
717
732
|
|
|
733
|
+
/** Holds on a peer's queue, for a drain and a pull alike: recovery, an uncertain delivery, a pause (console, budget, conductor). */
|
|
734
|
+
private held(id: PeerId): boolean {
|
|
735
|
+
return this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** What a peer was just handed stays resolvable for `reply_to`: moved to the newest end, the stored envelope kept. */
|
|
739
|
+
private remember(envs: Envelope[]): void {
|
|
740
|
+
for (const e of envs) { const kept = this.seen.get(e.id) ?? e; this.seen.delete(e.id); this.seen.set(e.id, kept); }
|
|
741
|
+
while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** A push may go out now: nothing holds the queue, the peer is idle, and its session shows pushes (issue #205). */
|
|
745
|
+
private deliverable(id: PeerId): boolean {
|
|
746
|
+
return !this.held(id) && this.stateOf(id) === "idle" && !this.peers.get(id)?.pullOnly;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* What one push delivery would take (the preface, then `take`'s batch), handed to a pull-only peer and recorded as
|
|
751
|
+
* completed: the tool result that returns it is the readback, so nothing waits for settlement and nothing is ever
|
|
752
|
+
* `accepted` (issue #205). It is checkpointed like a push, so queued rows of the same envelopes (an operator retry)
|
|
753
|
+
* are grouped into it, and registered for `reply_to` like a push. Undefined while held. A failed journal write puts
|
|
754
|
+
* back only this peer's queue and preface (every pause stays) and stops the bus like any other journal failure.
|
|
755
|
+
* ponytail: recorded completed at hand-out, before anything shows the model saw it: a reply lost on the way, a tool
|
|
756
|
+
* call cancelled (Esc) or a plugin that dies loses the batch, and `ahub queue resolve --action retry` refuses a
|
|
757
|
+
* terminal row. The completed row keeps the envelopes (`ahub queue show <id>`) to resend by hand; take/confirm in two
|
|
758
|
+
* steps if loss is ever observed.
|
|
759
|
+
*/
|
|
760
|
+
pull(id: PeerId): Envelope[] | undefined {
|
|
761
|
+
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
762
|
+
if (this.held(id)) return undefined;
|
|
763
|
+
const queue = this.queues.get(id) ?? [];
|
|
764
|
+
this.dropIrrelevant(id, queue);
|
|
765
|
+
const preface = this.prefaces.get(id);
|
|
766
|
+
if (!preface && !queue.length) return [];
|
|
767
|
+
const queued = [...queue];
|
|
768
|
+
const batch = [...(preface ? [preface] : []), ...this.take(id, queue)];
|
|
769
|
+
this.prefaces.delete(id);
|
|
770
|
+
// Before the checkpoint, as in a drain, so the persisted snapshot carries it; on a failure the bus stops anyway.
|
|
771
|
+
this.remember(batch);
|
|
772
|
+
try {
|
|
773
|
+
const deliveryId = crypto.randomUUID();
|
|
774
|
+
this.journal?.transaction(() => {
|
|
775
|
+
this.durableHandoff(id, deliveryId, batch, batch); // its bus snapshot is the queue after this pull
|
|
776
|
+
this.journal!.transition(deliveryId, "completed", "read through hub_inbox");
|
|
777
|
+
});
|
|
778
|
+
} catch (error) {
|
|
779
|
+
queue.splice(0, queue.length, ...queued);
|
|
780
|
+
if (preface) this.prefaces.set(id, preface);
|
|
781
|
+
this.storageError = "delivery journal unavailable";
|
|
782
|
+
throw error;
|
|
783
|
+
}
|
|
784
|
+
this.failureStreak.delete(id); this.lastFailure.delete(id); // read, as a completed push receipt clears them
|
|
785
|
+
this.onQueues?.();
|
|
786
|
+
return batch;
|
|
787
|
+
}
|
|
788
|
+
|
|
718
789
|
private async drain(id: PeerId): Promise<void> {
|
|
719
790
|
if (this.storageError || this.draining.has(id) || !this.peers.has(id)) return;
|
|
720
791
|
this.draining.add(id);
|
|
721
792
|
try {
|
|
722
793
|
const peer = this.peers.get(id)!;
|
|
723
794
|
const queue = this.queues.get(id)!;
|
|
724
|
-
while (!this.storageError &&
|
|
795
|
+
while (!this.storageError && queue.length && this.deliverable(id)) {
|
|
725
796
|
if (this.dropIrrelevant(id, queue) && !queue.length) break;
|
|
726
797
|
const delay = this.wait(queue);
|
|
727
798
|
if (delay > 0) { this.arm(id, delay); break; }
|
|
@@ -736,7 +807,8 @@ export class Bus {
|
|
|
736
807
|
this.condensing += mayCondense ? 1 : 0;
|
|
737
808
|
const out = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
|
|
738
809
|
this.condensing -= mayCondense ? 1 : 0;
|
|
739
|
-
|
|
810
|
+
// A session that cannot show pushes may have taken the peer while this was condensed (issue #205).
|
|
811
|
+
if (!this.deliverable(id)) {
|
|
740
812
|
if (!this.journal) { if (preface) this.restorePreface(id, preface); queue.unshift(...batch.filter((e) => !this.withdrawn.has(e.id))); }
|
|
741
813
|
break;
|
|
742
814
|
}
|
|
@@ -751,8 +823,7 @@ export class Bus {
|
|
|
751
823
|
for (const env of batch) queue.splice(queue.findIndex((item) => item.id === env.id), 1);
|
|
752
824
|
this.prefaces.delete(id);
|
|
753
825
|
}
|
|
754
|
-
|
|
755
|
-
while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
|
|
826
|
+
this.remember(out);
|
|
756
827
|
this.lastDelivery.set(id, { out, originals: delivery });
|
|
757
828
|
this.preparing.delete(id); // from here on the transport/journal owns settlement
|
|
758
829
|
const deliveryId = crypto.randomUUID();
|
package/src/hub/child-process.ts
CHANGED
|
@@ -17,6 +17,7 @@ export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.e
|
|
|
17
17
|
delete env.CLAUDECODE;
|
|
18
18
|
delete env.CLAUDE_CODE_SESSION_ID;
|
|
19
19
|
delete env.CODEX_THREAD_ID;
|
|
20
|
+
delete env.AGENTHUB_CHANNEL;
|
|
20
21
|
delete env.AGENTHUB_PEER_ID;
|
|
21
22
|
env.AGENTHUB_PEER_ID = peer;
|
|
22
23
|
return env;
|
package/src/hub/conductor.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { Task } from "./board.ts";
|
|
3
3
|
import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
|
|
4
|
+
import { HUB, USER } from "./envelope.ts";
|
|
5
|
+
import { OWNERSHIP_EVENTS } from "./tasks.ts";
|
|
6
|
+
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
4
7
|
import type { HubEvent } from "./events.ts";
|
|
5
8
|
import type { SupervisionFeed } from "./supervision.ts";
|
|
6
9
|
import type { Budget } from "./budget.ts";
|
|
@@ -22,8 +25,8 @@ export function publicPeerBudget(status: ReturnType<Budget["status"]>, nameable:
|
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
25
|
-
const peerId = (value: unknown): string => {
|
|
26
|
-
if (typeof value !== "string" || /\s/.test(value) || !PEER.test(value) || ["user", "hub", "digest"].includes(value)) throw new Error("peer must be a valid agent peer id");
|
|
28
|
+
const peerId = (value: unknown, preEffect = false): string => {
|
|
29
|
+
if (typeof value !== "string" || /\s/.test(value) || !PEER.test(value) || ["user", "hub", "digest"].includes(value)) throw new (preEffect ? PreEffectToolRefusal : Error)("peer must be a valid agent peer id");
|
|
27
30
|
return value;
|
|
28
31
|
};
|
|
29
32
|
|
|
@@ -43,12 +46,16 @@ export function conductorPeer(roles: unknown): string | null {
|
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/** The role check always comes first; default-allow capabilities cannot grant a role. */
|
|
46
|
-
export function requireConductor(peer: string, roles: unknown, capabilities: Record<string, unknown> = {}, assign = false): void {
|
|
47
|
-
if (conductorPeer(roles) !== peer) throw new Error("this operation requires the explicit conductor role");
|
|
48
|
-
if (assign
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
export function requireConductor(peer: string, roles: unknown, capabilities: Record<string, unknown> = {}, assign = false, preEffect = false): void {
|
|
50
|
+
if (conductorPeer(roles) !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)("this operation requires the explicit conductor role");
|
|
51
|
+
if (assign) requireAssign(peer, capabilities, preEffect);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A peer with an explicit capabilities list needs `assign` in it to move work. */
|
|
55
|
+
function requireAssign(peer: string, capabilities: Record<string, unknown>, preEffect = false): void {
|
|
56
|
+
if (!Object.hasOwn(capabilities, peer)) return;
|
|
57
|
+
const caps = capabilities[peer];
|
|
58
|
+
if (!Array.isArray(caps) || !caps.includes("assign")) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} requires assign capability for this operation`);
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
export interface ConductorHold { peer: string; actor: string; since: number }
|
|
@@ -95,11 +102,11 @@ export class ConductorHolds {
|
|
|
95
102
|
|
|
96
103
|
export type ConductorStart = { peer: "local" | "kimi" | "pi"; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
|
|
97
104
|
/** Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text. */
|
|
98
|
-
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string): ConductorStart {
|
|
99
|
-
if (mode !== undefined && mode !== "headless" && mode !== "tui") throw new Error("mode must be headless or tui");
|
|
105
|
+
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect = false): ConductorStart {
|
|
106
|
+
if (mode !== undefined && mode !== "headless" && mode !== "tui") throw new (preEffect ? PreEffectToolRefusal : Error)("mode must be headless or tui");
|
|
100
107
|
if (peer === "claude" || peer === "codex" || (peer === "pi" && mode === "tui")) return { peer, mode: "tui", command: preview(peer) };
|
|
101
108
|
if ((peer === "local" || peer === "kimi" || peer === "pi") && mode !== "tui") return { peer, mode: "headless" };
|
|
102
|
-
throw new Error("only local, kimi and headless pi may be started by the conductor");
|
|
109
|
+
throw new (preEffect ? PreEffectToolRefusal : Error)("only local, kimi and headless pi may be started by the conductor");
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
const numberOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
@@ -154,23 +161,49 @@ export interface ConductorHooks {
|
|
|
154
161
|
audit(event: ConductEvent): void;
|
|
155
162
|
}
|
|
156
163
|
|
|
164
|
+
/** A task id as `Tasks.need()` takes it: a whole number or a digit string. */
|
|
165
|
+
const taskId = (v: unknown): number | undefined => {
|
|
166
|
+
const n = typeof v === "number" ? v : typeof v === "string" && /^\s*\d+\s*$/.test(v) ? Number(v) : NaN;
|
|
167
|
+
return Number.isSafeInteger(n) && n > 0 ? n : undefined;
|
|
168
|
+
};
|
|
169
|
+
|
|
157
170
|
export class Conductor {
|
|
158
171
|
constructor(private readonly holds: ConductorHolds, private readonly hooks: ConductorHooks) {}
|
|
159
172
|
async execute(actor: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
|
|
160
|
-
if (!CONDUCTOR_TOOL_NAMES.has(tool)) throw new
|
|
161
|
-
|
|
173
|
+
if (!CONDUCTOR_TOOL_NAMES.has(tool)) throw new PreEffectToolRefusal("unknown conductor tool");
|
|
174
|
+
if ((tool === "hub_task_show" || tool === "hub_task_assign") && conductorPeer(this.hooks.roles()) !== actor) {
|
|
175
|
+
const id = taskId(args.id);
|
|
176
|
+
const task = id === undefined ? undefined : this.hooks.task(id);
|
|
177
|
+
// A task's current owner and reviewer read its public view without the role (#208); nobody else does.
|
|
178
|
+
if (task && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
|
|
179
|
+
// Its proposer (the first history entry) redirects it while nobody ever accepted it (#207: a decline or a release puts
|
|
180
|
+
// worked tasks back in proposed); work that waits gets a reserved owner. Never over the person: a console assign or
|
|
181
|
+
// reservation stands until the conductor or the console moves it, also once the hub carried it out (the hub's own
|
|
182
|
+
// moves are not decisions).
|
|
183
|
+
const moved = task?.history.findLast((h) => h.by !== HUB && (OWNERSHIP_EVENTS.has(h.event) || h.event === "reserved"));
|
|
184
|
+
const accepted = task?.history.some((h) => h.event === "accepted");
|
|
185
|
+
if (task && tool === "hub_task_assign" && task.history[0]?.by === actor && task.state === "proposed" && !accepted && moved?.by !== USER) {
|
|
186
|
+
const peer = peerId(args.peer, true);
|
|
187
|
+
if (peer !== actor) requireAssign(actor, this.hooks.capabilities(), true);
|
|
188
|
+
await this.hooks.assign(actor, task.id, peer);
|
|
189
|
+
const updated = this.hooks.task(task.id);
|
|
190
|
+
return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
requireConductor(actor, this.hooks.roles(), this.hooks.capabilities(), tool === "hub_task_assign" || tool === "hub_task_escalate", true);
|
|
162
194
|
const action = tool.slice(4);
|
|
163
195
|
const emit = (extra: Pick<ConductEvent, "task" | "peer"> = {}) => {
|
|
164
196
|
try { this.hooks.audit({ kind: "conduct", actor, action, ...extra }); } catch { /* never throw after a task or hold write */ }
|
|
165
197
|
};
|
|
166
198
|
if (tool === "hub_status") { const result = publicConductorStatus(this.hooks.status()); emit(); return result; }
|
|
167
199
|
if (tool.startsWith("hub_task_")) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
200
|
+
const id = taskId(args.id);
|
|
201
|
+
if (id === undefined) throw new PreEffectToolRefusal("id must be a positive integer");
|
|
202
|
+
const task = this.hooks.task(id);
|
|
203
|
+
if (!task) throw new PreEffectToolRefusal(`no task #${args.id}`);
|
|
171
204
|
if (tool === "hub_task_show") { const result = publicConductorTask(task, this.hooks.publicView); emit({ task: task.id }); return result; }
|
|
172
205
|
if (tool === "hub_task_assign") {
|
|
173
|
-
const peer = peerId(args.peer);
|
|
206
|
+
const peer = peerId(args.peer, true);
|
|
174
207
|
await this.hooks.assign(actor, task.id, peer); emit({ task: task.id, peer });
|
|
175
208
|
} else { await this.hooks.escalate(actor, task.id); emit({ task: task.id }); }
|
|
176
209
|
// Task callbacks may return raw objects; always re-read and apply the public view.
|
|
@@ -178,15 +211,15 @@ export class Conductor {
|
|
|
178
211
|
return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
|
|
179
212
|
}
|
|
180
213
|
if (tool === "hub_peer_start") {
|
|
181
|
-
const start = conductorStart(args.peer, args.mode, this.hooks.preview);
|
|
214
|
+
const start = conductorStart(args.peer, args.mode, this.hooks.preview, true);
|
|
182
215
|
if (start.mode === "headless") await this.hooks.start(start.peer);
|
|
183
216
|
emit({ peer: start.peer }); return start;
|
|
184
217
|
}
|
|
185
|
-
const peer = peerId(args.peer);
|
|
186
|
-
if (!this.hooks.known(peer)) throw new
|
|
218
|
+
const peer = peerId(args.peer, true);
|
|
219
|
+
if (!this.hooks.known(peer)) throw new PreEffectToolRefusal(`unknown peer: ${peer}`);
|
|
187
220
|
if (tool === "hub_peer_hold") { this.holds.hold(peer, actor); this.hooks.pause(peer); }
|
|
188
221
|
else {
|
|
189
|
-
if (this.holds.get(peer)?.actor !== actor) throw new
|
|
222
|
+
if (this.holds.get(peer)?.actor !== actor) throw new PreEffectToolRefusal("you may release only a conductor hold you placed");
|
|
190
223
|
await this.hooks.validateRelease?.(peer);
|
|
191
224
|
requireConductor(actor, this.hooks.roles(), this.hooks.capabilities());
|
|
192
225
|
this.holds.release(peer, actor); this.hooks.release(peer);
|
package/src/hub/config-trust.ts
CHANGED
|
@@ -6,15 +6,17 @@ export function stateDirFor(cwd: string): string {
|
|
|
6
6
|
return projectContext(cwd).stateDir;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
|
-
export const PROTOCOL =
|
|
9
|
+
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
|
+
export const PROTOCOL = 16;
|
|
11
11
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
12
|
-
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, PROTOCOL] as const;
|
|
12
|
+
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
13
13
|
|
|
14
14
|
export interface Hello {
|
|
15
15
|
/** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
|
|
16
16
|
role: "peer" | "console" | "tools";
|
|
17
17
|
peer?: string;
|
|
18
|
+
/** `peer` only: false when the session cannot show channel pushes; its queue then waits for `inbox` (issue #205). */
|
|
19
|
+
channel?: boolean;
|
|
18
20
|
projectId?: string;
|
|
19
21
|
instanceId?: string;
|
|
20
22
|
projectRoot?: string;
|
|
@@ -28,6 +30,8 @@ export interface ControlDescriptor {
|
|
|
28
30
|
cwd?: string;
|
|
29
31
|
protocol?: number;
|
|
30
32
|
pid?: number;
|
|
33
|
+
/** #226: the daemon's processSignature, written into status.json only (not the status reply); absent before 0.12.21. */
|
|
34
|
+
pidSignature?: string;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/** Where the daemon of this state dir listens. Re-read before every connect: port and token change per run. */
|
|
@@ -37,7 +41,8 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
|
|
|
37
41
|
const token = readFileSync(join(stateDir, "control-token"), "utf8").trim();
|
|
38
42
|
if (!Number.isInteger(status.controlPort) || status.controlPort < 1 || status.controlPort > 65535 || !token) return undefined;
|
|
39
43
|
return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
|
|
40
|
-
instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid
|
|
44
|
+
instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid,
|
|
45
|
+
...(typeof status.pidSignature === "string" && status.pidSignature ? { pidSignature: status.pidSignature } : {}) };
|
|
41
46
|
} catch {
|
|
42
47
|
return undefined;
|
|
43
48
|
}
|