@staix/agent-hub 0.12.20 → 0.12.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +6 -5
- package/docs/agent-notes/adapters.md +9 -2
- package/docs/agent-notes/bus.md +5 -2
- package/docs/agent-notes/daemon.md +8 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +2 -1
- package/docs/operations.md +271 -46
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +45 -10
- package/docs/smoke.md +79 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +194 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +68 -10
- package/docs/verified.json +28 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3 -2
- package/src/adapters/acp.ts +67 -6
- package/src/adapters/codex-appserver.ts +67 -2
- package/src/adapters/local-worker.ts +4 -3
- package/src/adapters/pi.ts +58 -4
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +226 -28
- package/src/cli/console.ts +16 -4
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +10 -0
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +96 -17
- package/src/cli/main.ts +159 -18
- package/src/cli/preview.ts +13 -3
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-runtime.ts +67 -18
- package/src/cli/upgrade.ts +81 -9
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +19 -4
- package/src/hub/conductor.ts +22 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +346 -40
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +2 -1
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +21 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +70 -43
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/tools.ts +14 -10
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +83 -6
- package/src/ui/task-progress.ts +33 -0
|
@@ -0,0 +1,255 @@
|
|
|
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 type { StampedEvent } from "./events.ts";
|
|
5
|
+
import { hubHome } from "./project.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #247: opt-in research records of how tasks went, built only from `events.jsonl` (ids, states, counts, tokens), so the
|
|
9
|
+
* live writer and `ahub research backfill` produce the same records. Never a title, detail, plan, note, path, message
|
|
10
|
+
* body or check output: the events carry none of those.
|
|
11
|
+
*/
|
|
12
|
+
export const RESEARCH_SCHEMA = "agent-hub.research/v1";
|
|
13
|
+
export const LABELS = ["ok", "regressed", "reverted", "incomplete", "wrong", "abandoned"] as const;
|
|
14
|
+
export type Label = (typeof LABELS)[number];
|
|
15
|
+
/** A person's later verdict that turns an approved task into a failure. */
|
|
16
|
+
const FAILED: ReadonlySet<string> = new Set(["regressed", "reverted", "incomplete", "wrong", "abandoned"]);
|
|
17
|
+
|
|
18
|
+
export interface PeerUsage { input: number | null; output: number | null; cacheRead: number | null; total: number | null }
|
|
19
|
+
export interface TaskRecord {
|
|
20
|
+
schema: typeof RESEARCH_SCHEMA; kind: "task"; project: string; task: number;
|
|
21
|
+
class: string | null; pii: boolean; outcome: "approved";
|
|
22
|
+
/** The proposal time also tells a task from an earlier one with the same id after `ahub reset --all`. */
|
|
23
|
+
createdAt: string | null; startedAt: string | null; approvedAt: string;
|
|
24
|
+
/** From the first `in_progress` to the approval; null when the start is not in the events. */
|
|
25
|
+
wallMs: number | null;
|
|
26
|
+
/** Sum of the attributed turns' durations, the turns still open at the approval included. */
|
|
27
|
+
activeMs: number;
|
|
28
|
+
/** Owners in the order they held the task. */
|
|
29
|
+
owners: string[]; reviewer: string | null;
|
|
30
|
+
/** Times the task went to review, was sent back, passed or failed its check, was reported done. */
|
|
31
|
+
reviewRounds: number; changesRequested: number; checkPassed: number; checkFailed: number; dones: number;
|
|
32
|
+
/** Owner changes after the first owner, and the same counted by the board's move reason. */
|
|
33
|
+
reassignments: number; reassignedBy: Record<string, number>;
|
|
34
|
+
/** Approved on its first review with no failed check. */
|
|
35
|
+
firstPass: boolean;
|
|
36
|
+
stuck: number; overlaps: number; conflicts: number;
|
|
37
|
+
tests: { pass: number; fail: number };
|
|
38
|
+
/** Attributed tokens per peer and per #200 attribution rule; unattributed tokens are in no record. */
|
|
39
|
+
tokens: { total: number; byPeer: Record<string, number>; byAttribution: Record<string, number> };
|
|
40
|
+
usage: Record<string, PeerUsage>;
|
|
41
|
+
/** Provider-reported total tokens per served (else requested) model. */
|
|
42
|
+
usageByModel: Record<string, number>;
|
|
43
|
+
turns: number; filesChanged: number;
|
|
44
|
+
/** Model routes and served models seen for the task's attributed requests. */
|
|
45
|
+
models: string[];
|
|
46
|
+
writer: { version: string; source: "live" | "backfill" };
|
|
47
|
+
}
|
|
48
|
+
/** A person's later verdict on one task record: `createdAt` binds it to that task, not a later one with the same id. */
|
|
49
|
+
export interface LabelRecord { schema: typeof RESEARCH_SCHEMA; kind: "label"; project: string; task: number; createdAt: string; label: Label; at: string }
|
|
50
|
+
export type ResearchRecord = TaskRecord | LabelRecord;
|
|
51
|
+
|
|
52
|
+
/** The store name for a project: a hash of its id, never its path. */
|
|
53
|
+
export const projectKey = (projectId: string): string => createHash("sha256").update(projectId).digest("hex").slice(0, 16);
|
|
54
|
+
export const researchDir = (home = hubHome()): string => join(home, "research");
|
|
55
|
+
export const researchFile = (projectId: string, home = hubHome()): string => join(researchDir(home), `${projectKey(projectId)}.jsonl`);
|
|
56
|
+
/** One task across every record about it: a task id is reused after `ahub reset --all`, its proposal time is not. */
|
|
57
|
+
export const recordKey = (r: { project: string; task: number; createdAt: string | null; approvedAt?: string }): string => `${r.project}#${r.task}@${r.createdAt ?? r.approvedAt ?? ""}`;
|
|
58
|
+
|
|
59
|
+
type Acc = Omit<TaskRecord, "schema" | "kind" | "project" | "approvedAt" | "wallMs" | "firstPass" | "outcome" | "writer" | "models"> & {
|
|
60
|
+
models: Set<string>; approvedAt: string | null; lastState: string | null;
|
|
61
|
+
};
|
|
62
|
+
/** Keyed by peer ids, model names and reasons, any of which may be `constructor` or `__proto__`: no prototype to inherit from. */
|
|
63
|
+
const keyed = <T>(): Record<string, T> => Object.create(null) as Record<string, T>;
|
|
64
|
+
const blank = (task: number): Acc => ({ task, class: null, pii: false, createdAt: null, startedAt: null, activeMs: 0, owners: [], reviewer: null,
|
|
65
|
+
reviewRounds: 0, changesRequested: 0, checkPassed: 0, checkFailed: 0, dones: 0, reassignments: 0, reassignedBy: keyed(), stuck: 0, overlaps: 0, conflicts: 0,
|
|
66
|
+
tests: { pass: 0, fail: 0 }, tokens: { total: 0, byPeer: keyed(), byAttribution: keyed() }, usage: keyed(), usageByModel: keyed(), turns: 0, filesChanged: 0, models: new Set(),
|
|
67
|
+
approvedAt: null, lastState: null });
|
|
68
|
+
const add = (current: number | null, value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? (current ?? 0) + value : current;
|
|
69
|
+
const bump = (counts: Record<string, number>, key: string, n = 1) => { counts[key] = (counts[key] ?? 0) + n; };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One record per approved task in `events`. Approval is final on the board, so everything attributed to the task
|
|
73
|
+
* counts, also after the approval: the turn that approved it ends later, and its usage arrives after that. The live
|
|
74
|
+
* writer therefore waits for those turns (daemon `recordResearch`); backfill reads to the end of the file.
|
|
75
|
+
*/
|
|
76
|
+
export function taskRecords(events: StampedEvent[], projectId: string, writer: TaskRecord["writer"]): TaskRecord[] {
|
|
77
|
+
const tasks = new Map<number, Acc>(), earlier: Acc[] = [];
|
|
78
|
+
const acc = (id: number) => { let a = tasks.get(id); if (!a) tasks.set(id, a = blank(id)); return a; };
|
|
79
|
+
const seenUsage = new Set<string>();
|
|
80
|
+
for (const e of events) {
|
|
81
|
+
if (e.type === "task") {
|
|
82
|
+
// Ids restart after `ahub reset --all` archives the events, but a file that kept both starts the task anew.
|
|
83
|
+
if (e.event === "proposed" && tasks.get(e.id)?.createdAt) { earlier.push(tasks.get(e.id)!); tasks.delete(e.id); }
|
|
84
|
+
const a = acc(e.id);
|
|
85
|
+
a.class = e.class; if (e.pii) a.pii = true;
|
|
86
|
+
if (e.owner && e.owner !== a.owners.at(-1)) {
|
|
87
|
+
if (a.owners.length) { a.reassignments++; bump(a.reassignedBy, e.reason ?? "none"); }
|
|
88
|
+
a.owners.push(e.owner);
|
|
89
|
+
}
|
|
90
|
+
if (e.reviewer) a.reviewer = e.reviewer;
|
|
91
|
+
if (e.event === "proposed") a.createdAt ??= e.at;
|
|
92
|
+
if (e.state === "in_progress") a.startedAt ??= e.at;
|
|
93
|
+
if (e.event === "done") a.dones++;
|
|
94
|
+
if (e.event === "check passed") a.checkPassed++;
|
|
95
|
+
if (e.event === "check failed") a.checkFailed++;
|
|
96
|
+
if (e.state === "in_review" && a.lastState !== "in_review") a.reviewRounds++;
|
|
97
|
+
if (e.state === "changes_requested" && a.lastState !== "changes_requested") a.changesRequested++;
|
|
98
|
+
if (e.state === "approved") a.approvedAt ??= e.at;
|
|
99
|
+
a.lastState = e.state;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const task = "task" in e && typeof e.task === "number" ? e.task : undefined;
|
|
103
|
+
if (task === undefined) continue;
|
|
104
|
+
const a = acc(task);
|
|
105
|
+
if ("pii" in e && e.pii === true) a.pii = true;
|
|
106
|
+
if (e.type === "tokens") { a.tokens.total += e.n; bump(a.tokens.byPeer, e.peer, e.n); bump(a.tokens.byAttribution, e.attribution ?? "unknown", e.n); }
|
|
107
|
+
else if (e.type === "turn_end") { a.turns++; a.activeMs += e.ms; a.filesChanged += e.files ?? 0; }
|
|
108
|
+
else if (e.type === "usage") {
|
|
109
|
+
const key = `${e.peer}\0${e.source}\0${e.id}`;
|
|
110
|
+
if (seenUsage.has(key)) continue;
|
|
111
|
+
seenUsage.add(key);
|
|
112
|
+
const u = a.usage[e.peer] ??= { input: null, output: null, cacheRead: null, total: null };
|
|
113
|
+
u.input = add(u.input, e.inputTokens); u.output = add(u.output, e.outputTokens); u.cacheRead = add(u.cacheRead, e.cacheReadTokens); u.total = add(u.total, e.totalTokens);
|
|
114
|
+
const model = e.servedModel ?? e.requestedModel;
|
|
115
|
+
if (model) a.models.add(String(model));
|
|
116
|
+
if (typeof e.totalTokens === "number" && Number.isFinite(e.totalTokens)) bump(a.usageByModel, String(model ?? "unknown"), e.totalTokens);
|
|
117
|
+
}
|
|
118
|
+
else if (e.type === "route") a.models.add(e.route);
|
|
119
|
+
else if (e.type === "route_outcome") { if (e.next?.tests === "pass") a.tests.pass++; if (e.next?.tests === "fail") a.tests.fail++; }
|
|
120
|
+
else if (e.type === "stuck") a.stuck++;
|
|
121
|
+
else if (e.type === "overlap") a.overlaps++;
|
|
122
|
+
else if (e.type === "conflict") a.conflicts++;
|
|
123
|
+
}
|
|
124
|
+
const out: TaskRecord[] = [];
|
|
125
|
+
for (const a of [...earlier, ...tasks.values()]) {
|
|
126
|
+
if (!a.approvedAt) continue;
|
|
127
|
+
const { approvedAt, lastState: _state, models, ...rest } = a;
|
|
128
|
+
const start = a.startedAt ? Date.parse(a.startedAt) : NaN, end = Date.parse(approvedAt);
|
|
129
|
+
out.push({ schema: RESEARCH_SCHEMA, kind: "task", project: projectKey(projectId), ...rest, outcome: "approved", approvedAt,
|
|
130
|
+
wallMs: Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : null,
|
|
131
|
+
firstPass: a.changesRequested === 0 && a.checkFailed === 0 && a.reviewRounds <= 1, models: [...models].sort(), writer });
|
|
132
|
+
}
|
|
133
|
+
return out.sort((x, y) => x.approvedAt.localeCompare(y.approvedAt) || x.task - y.task);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Every record in one store file; a line that does not parse is skipped. */
|
|
137
|
+
export function readStore(file: string): ResearchRecord[] {
|
|
138
|
+
if (!existsSync(file)) return [];
|
|
139
|
+
return readFileSync(file, "utf8").split("\n").flatMap((line) => {
|
|
140
|
+
if (!line.trim()) return [];
|
|
141
|
+
try { const r = JSON.parse(line); return r?.schema === RESEARCH_SCHEMA ? [r as ResearchRecord] : []; } catch { return []; }
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** This project's store, or every project's with `all`. */
|
|
145
|
+
export function readStores(projectId: string | undefined, home = hubHome()): ResearchRecord[] {
|
|
146
|
+
if (projectId) return readStore(researchFile(projectId, home));
|
|
147
|
+
const dir = researchDir(home);
|
|
148
|
+
if (!existsSync(dir)) return [];
|
|
149
|
+
return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).sort().flatMap((name) => readStore(join(dir, name)));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Append task records the store does not hold yet (same task, by `recordKey`), and labels; returns how many were written. */
|
|
153
|
+
export function appendRecords(projectId: string, records: ResearchRecord[], home = hubHome()): number {
|
|
154
|
+
if (!records.length) return 0;
|
|
155
|
+
const file = researchFile(projectId, home);
|
|
156
|
+
mkdirSync(researchDir(home), { recursive: true, mode: 0o700 });
|
|
157
|
+
chmodSync(researchDir(home), 0o700); // mkdir's mode applies only when it creates the directory
|
|
158
|
+
const held = new Set(readStore(file).flatMap((r) => r.kind === "task" ? [recordKey(r)] : []));
|
|
159
|
+
const added = records.filter((r) => r.kind === "label" || !held.has(recordKey(r)));
|
|
160
|
+
if (!added.length) return 0;
|
|
161
|
+
appendFileSync(file, added.map((r) => JSON.stringify(r)).join("\n") + "\n", { mode: 0o600 });
|
|
162
|
+
chmodSync(file, 0o600);
|
|
163
|
+
return added.length;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The record a person's label is for. `created` (a record's `createdAt`, from `ahub research export`) names one exactly.
|
|
168
|
+
* Otherwise the current board's task with that id, found by its proposal in `events`, so a label never lands on an
|
|
169
|
+
* earlier task with the same id; an id this hub's events do not have (a task from before `ahub reset --all`) is the
|
|
170
|
+
* store's only record with that id. Throws why there is none, or which ones to choose from.
|
|
171
|
+
*/
|
|
172
|
+
export function labelTarget(records: ResearchRecord[], events: StampedEvent[], task: number, created?: string): TaskRecord {
|
|
173
|
+
// A record without a proposal time (its proposal was not in the events) cannot be told apart, so it takes no label.
|
|
174
|
+
const mine = records.filter((r): r is TaskRecord => r.kind === "task" && r.task === task && r.createdAt !== null);
|
|
175
|
+
if (created !== undefined) {
|
|
176
|
+
return mine.find((r) => r.createdAt === created) ?? fail(`no research record for task #${task} proposed at ${created}${mine.length ? ` (it has: ${mine.map((r) => r.createdAt).join(", ")})` : ""}`);
|
|
177
|
+
}
|
|
178
|
+
const proposed = events.filter((e) => e.type === "task" && e.id === task && e.event === "proposed").at(-1);
|
|
179
|
+
if (proposed) {
|
|
180
|
+
return mine.find((r) => r.createdAt === proposed.at)
|
|
181
|
+
?? fail(`task #${task} has no research record yet: it is not approved, its record still waits for the turns that approved it, or it was approved while research was off (ahub research backfill builds that one)${mine.length ? `; an earlier task #${task} from before a reset is labelled with --created ${mine.map((r) => r.createdAt).join(" or ")}` : ""}`);
|
|
182
|
+
}
|
|
183
|
+
if (mine.length === 1) return mine[0]!;
|
|
184
|
+
if (!mine.length) throw new Error(records.some((r) => r.kind === "task" && r.task === task) ? `task #${task}'s research record has no proposal time (its proposal was not in the events), so it takes no label` : `no research record for task #${task}`);
|
|
185
|
+
throw new Error(`task #${task} is not on this hub's board and the store has ${mine.length} records with that id; name one with --created <time>: ${mine.map((r) => r.createdAt).join(", ")}`);
|
|
186
|
+
}
|
|
187
|
+
const fail = (message: string): never => { throw new Error(message); };
|
|
188
|
+
|
|
189
|
+
const pct = (n: number, d: number) => d ? n / d : null;
|
|
190
|
+
/** Linear interpolation between the closest ranks: the median of [100, 300] is 200. */
|
|
191
|
+
function quantile(values: number[], q: number): number | null {
|
|
192
|
+
if (!values.length) return null;
|
|
193
|
+
const sorted = [...values].sort((a, b) => a - b), at = q * (sorted.length - 1), low = Math.floor(at);
|
|
194
|
+
return sorted[low]! + (sorted[Math.min(low + 1, sorted.length - 1)]! - sorted[low]!) * (at - low);
|
|
195
|
+
}
|
|
196
|
+
export interface Measures {
|
|
197
|
+
tasks: number; succeeded: number; failedByLabel: number;
|
|
198
|
+
successRate: number | null; firstPassRate: number | null; reworkRate: number | null; checkFailureRate: number | null;
|
|
199
|
+
tokensPerTask: { median: number | null; p90: number | null }; wallMsPerTask: { median: number | null; p90: number | null };
|
|
200
|
+
}
|
|
201
|
+
function measures(records: TaskRecord[], labels: Map<string, Label>): Measures {
|
|
202
|
+
const failed = records.filter((r) => FAILED.has(labels.get(recordKey(r)) ?? "")).length;
|
|
203
|
+
return { tasks: records.length, succeeded: records.length - failed, failedByLabel: failed,
|
|
204
|
+
successRate: pct(records.length - failed, records.length), firstPassRate: pct(records.filter((r) => r.firstPass).length, records.length),
|
|
205
|
+
reworkRate: pct(records.reduce((n, r) => n + r.changesRequested, 0), records.length),
|
|
206
|
+
checkFailureRate: pct(records.reduce((n, r) => n + r.checkFailed, 0), records.reduce((n, r) => n + r.checkFailed + r.checkPassed, 0)),
|
|
207
|
+
tokensPerTask: { median: quantile(records.map((r) => r.tokens.total), 0.5), p90: quantile(records.map((r) => r.tokens.total), 0.9) },
|
|
208
|
+
wallMsPerTask: { median: quantile(records.flatMap((r) => r.wallMs ?? []), 0.5), p90: quantile(records.flatMap((r) => r.wallMs ?? []), 0.9) } };
|
|
209
|
+
}
|
|
210
|
+
export interface ResearchReport { overall: Measures; byClass: Record<string, Measures>; byOwner: Record<string, Measures>; byProject: Record<string, Measures>; byModel: Record<string, Measures> }
|
|
211
|
+
/**
|
|
212
|
+
* The derived measures, never stored as truth: each task counts once (its last record), and a person's latest label
|
|
213
|
+
* decides whether an approved task later failed. Records older than `since` (ms epoch, by approval) are left out.
|
|
214
|
+
*/
|
|
215
|
+
export function researchReport(records: ResearchRecord[], since = 0): ResearchReport {
|
|
216
|
+
const latest = new Map<string, TaskRecord>(), labels = new Map<string, Label>();
|
|
217
|
+
for (const r of records) {
|
|
218
|
+
const key = recordKey(r);
|
|
219
|
+
if (r.kind === "label") { labels.set(key, r.label); continue; }
|
|
220
|
+
if (Date.parse(r.approvedAt) >= since) latest.set(key, r);
|
|
221
|
+
}
|
|
222
|
+
const all = [...latest.values()];
|
|
223
|
+
const group = (keys: (r: TaskRecord) => string[]) => {
|
|
224
|
+
// A Map: a peer or model may be named `constructor`, which a plain object answers with an inherited value.
|
|
225
|
+
const out = new Map<string, TaskRecord[]>();
|
|
226
|
+
for (const r of all) for (const k of keys(r)) out.set(k, [...(out.get(k) ?? []), r]);
|
|
227
|
+
return Object.fromEntries([...out].sort(([a], [b]) => a.localeCompare(b)).map(([k, rs]) => [k, measures(rs, labels)]));
|
|
228
|
+
};
|
|
229
|
+
return { overall: measures(all, labels), byClass: group((r) => [r.class ?? "unknown"]), byOwner: group((r) => r.owners.length ? [...new Set(r.owners)] : ["none"]),
|
|
230
|
+
byProject: group((r) => [r.project]), byModel: group((r) => r.models.length ? r.models : ["unknown"]) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const fmtPct = (v: number | null) => v === null ? "-" : `${Math.round(v * 100)}%`;
|
|
234
|
+
const fmtNum = (v: number | null) => v === null ? "-" : String(Math.round(v));
|
|
235
|
+
export function formatResearch(r: ResearchReport): string[] {
|
|
236
|
+
const row = (name: string, m: Measures) => `${name.padEnd(24)} ${String(m.tasks).padStart(5)} ${fmtPct(m.successRate).padStart(7)} ${fmtPct(m.firstPassRate).padStart(10)} ${(m.reworkRate === null ? "-" : m.reworkRate.toFixed(2)).padStart(6)} ${fmtPct(m.checkFailureRate).padStart(11)} ${fmtNum(m.tokensPerTask.median).padStart(10)} ${fmtNum(m.tokensPerTask.p90).padStart(10)} ${(m.wallMsPerTask.median === null ? "-" : `${Math.round(m.wallMsPerTask.median / 60_000)}m`).padStart(8)}`;
|
|
237
|
+
const head = `${"".padEnd(24)} ${"tasks".padStart(5)} ${"success".padStart(7)} ${"first pass".padStart(10)} ${"rework".padStart(6)} ${"check fails".padStart(11)} ${"tokens p50".padStart(10)} ${"tokens p90".padStart(10)} ${"wall p50".padStart(8)}`;
|
|
238
|
+
const section = (title: string, groups: Record<string, Measures>) => Object.keys(groups).length ? [``, title, head, ...Object.entries(groups).map(([k, m]) => row(k, m))] : [];
|
|
239
|
+
return [head, row("all approved tasks", r.overall), ...section("by class", r.byClass), ...section("by owner", r.byOwner), ...section("by model", r.byModel), ...section("by project", r.byProject)];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const CSV_COLUMNS = ["project", "task", "class", "pii", "outcome", "createdAt", "startedAt", "approvedAt", "wallMs", "activeMs", "owners",
|
|
243
|
+
"reviewer", "reviewRounds", "changesRequested", "checkPassed", "checkFailed", "dones", "reassignments", "firstPass", "stuck", "overlaps", "conflicts",
|
|
244
|
+
"testsPass", "testsFail", "tokens", "turns", "filesChanged", "models", "label", "writerVersion", "writerSource"] as const;
|
|
245
|
+
/** Task records as CSV with a header row, one row per record, each with the task's latest label; JSONL has every field. */
|
|
246
|
+
export function toCsv(records: ResearchRecord[]): string {
|
|
247
|
+
const labels = new Map<string, Label>();
|
|
248
|
+
for (const r of records) if (r.kind === "label") labels.set(recordKey(r), r.label);
|
|
249
|
+
const cell = (v: unknown) => { const s = v === null || v === undefined ? "" : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
|
|
250
|
+
const rows = records.filter((r): r is TaskRecord => r.kind === "task").map((r) => [r.project, r.task, r.class, r.pii, r.outcome, r.createdAt, r.startedAt,
|
|
251
|
+
r.approvedAt, r.wallMs, r.activeMs, r.owners.join(" "), r.reviewer, r.reviewRounds, r.changesRequested, r.checkPassed, r.checkFailed, r.dones,
|
|
252
|
+
r.reassignments, r.firstPass, r.stuck, r.overlaps, r.conflicts, r.tests.pass, r.tests.fail, r.tokens.total, r.turns, r.filesChanged, r.models.join(" "),
|
|
253
|
+
labels.get(recordKey(r)) ?? "", r.writer.version, r.writer.source].map(cell).join(","));
|
|
254
|
+
return [CSV_COLUMNS.join(","), ...rows].join("\n") + "\n";
|
|
255
|
+
}
|
package/src/hub/tasks.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignm
|
|
|
9
9
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
10
10
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
11
11
|
import { realPath } from "./project.ts";
|
|
12
|
+
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
12
13
|
import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
13
14
|
|
|
14
15
|
export interface TasksDeps {
|
|
@@ -470,7 +471,7 @@ export class Tasks {
|
|
|
470
471
|
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown }): Promise<Task> {
|
|
471
472
|
// Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
|
|
472
473
|
const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
473
|
-
if (!title) throw new
|
|
474
|
+
if (!title) throw new PreEffectToolRefusal("title is required");
|
|
474
475
|
// Dependencies can only name tasks that exist, so the new task closes no cycle: nothing can depend on it yet.
|
|
475
476
|
// Callers are models: a missing or null `after` is no dependency, and an id is an integer or a digit string.
|
|
476
477
|
let afterIds: unknown[] = [];
|
|
@@ -479,14 +480,14 @@ export class Tasks {
|
|
|
479
480
|
const deps: number[] = [];
|
|
480
481
|
for (const v of afterIds) {
|
|
481
482
|
const id = typeof v === "number" || (typeof v === "string" && /^\s*\d+\s*$/.test(v)) ? Number(v) : NaN;
|
|
482
|
-
if (!Number.isInteger(id)) throw new
|
|
483
|
-
if (!this.d.board.get(id)) throw new
|
|
483
|
+
if (!Number.isInteger(id)) throw new PreEffectToolRefusal(`after: ${JSON.stringify(v)} is not a task id`);
|
|
484
|
+
if (!this.d.board.get(id)) throw new PreEffectToolRefusal(`after: no task #${id}`);
|
|
484
485
|
if (!deps.includes(id)) deps.push(id);
|
|
485
486
|
}
|
|
486
487
|
// An owner for work that waits is reserved (issue #207): routing offers it to that peer first once it is ready.
|
|
487
488
|
const reserved = this.waitsFor({ deps }).length ? input.owner : undefined;
|
|
488
489
|
const given = input.class === undefined || input.class === "" ? undefined : input.class;
|
|
489
|
-
if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new
|
|
490
|
+
if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new PreEffectToolRefusal(`class must be one of ${CLASSES.join(", ")}`);
|
|
490
491
|
const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
|
|
491
492
|
const plan = cleanPlan(input.plan);
|
|
492
493
|
// The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
|
|
@@ -511,7 +512,7 @@ export class Tasks {
|
|
|
511
512
|
// A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
|
|
512
513
|
const defaulted = !cls && input.owner === by;
|
|
513
514
|
if (defaulted) cls = "implement";
|
|
514
|
-
if (!cls) throw new
|
|
515
|
+
if (!cls) throw new PreEffectToolRefusal(`class is required (one of ${CLASSES.join(", ")}); ${fenced ? "the task is handled as PII and the hub's model is not reached on campus, so it was not asked to name one" : "the hub could not name one for you"}`);
|
|
515
516
|
const draft = { ...text, class: cls };
|
|
516
517
|
let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals });
|
|
517
518
|
if (screening) task = this.screenedTask(task, screened);
|
|
@@ -902,26 +903,36 @@ export class Tasks {
|
|
|
902
903
|
}
|
|
903
904
|
|
|
904
905
|
/** Nobody, the console user included, works on a task before what it waits for is approved. */
|
|
905
|
-
private ready(task: Task): void {
|
|
906
|
+
private ready(task: Task, preEffect = false): void {
|
|
906
907
|
const waits = this.waitsFor(task);
|
|
907
|
-
if (waits.length) throw new Error(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
|
|
908
|
+
if (waits.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
|
|
908
909
|
}
|
|
909
910
|
|
|
910
911
|
/** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
|
|
911
912
|
private async releaseDependents(approved: Task): Promise<void> {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
913
|
+
try {
|
|
914
|
+
for (const t of this.d.board.list("proposed")) {
|
|
915
|
+
if (!t.deps?.includes(approved.id) || t.owner || this.waitsFor(t).length) continue;
|
|
916
|
+
await this.offerReady(t, `#${approved.id} approved`);
|
|
917
|
+
}
|
|
918
|
+
} catch (e) {
|
|
919
|
+
this.releaseNotice(`task #${approved.id}: could not release dependents: ${e instanceof Error ? e.message : String(e)}`);
|
|
915
920
|
}
|
|
916
921
|
}
|
|
917
922
|
|
|
918
|
-
|
|
923
|
+
/** Notifications may read the same unavailable board; they must not undo a saved approval's return. */
|
|
924
|
+
private releaseNotice(line: string): void {
|
|
925
|
+
try { this.d.notify(line); } catch { /* best effort; the release timer retries ownerless work */ }
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
private readonly offered = new Set<number>(); // assignments in this hub run, including offers still in flight
|
|
919
929
|
private readonly sent = new Map<string, PeerId>(); // hand-over (`handOver`) -> owner, for the owner's current turn: its task envelope started it, or it claimed the task in it
|
|
920
930
|
|
|
921
931
|
/**
|
|
922
932
|
* A stop between an approval and the assignment of its dependents (both are saved on their own) leaves them ownerless
|
|
923
933
|
* with nothing left to wait for, and no later approval to offer them. The daemon calls this on its release timer:
|
|
924
|
-
* each such task is offered once
|
|
934
|
+
* each such task is offered once an attached peer can take it (peers attach one by one). Only an assignment uses
|
|
935
|
+
* up its offer for this hub run; a failed or ownerless offer stays eligible, after approval and on the timer alike.
|
|
925
936
|
*/
|
|
926
937
|
async releaseReady(): Promise<void> {
|
|
927
938
|
for (const t of this.d.board.list("proposed")) {
|
|
@@ -936,26 +947,41 @@ export class Tasks {
|
|
|
936
947
|
|
|
937
948
|
/** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
|
|
938
949
|
private async offerReady(stale: Task, why: string): Promise<void> {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
950
|
+
let offering = false;
|
|
951
|
+
try {
|
|
952
|
+
const t = this.d.board.get(stale.id);
|
|
953
|
+
if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
|
|
954
|
+
this.offered.add(t.id);
|
|
955
|
+
offering = true;
|
|
956
|
+
const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
|
|
957
|
+
this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
|
|
958
|
+
await this.assignOwner(ready, HUB);
|
|
959
|
+
} catch (e) {
|
|
960
|
+
this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
|
|
961
|
+
} finally {
|
|
962
|
+
if (offering) {
|
|
963
|
+
try {
|
|
964
|
+
if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
|
|
965
|
+
} catch (e) {
|
|
966
|
+
this.offered.delete(stale.id);
|
|
967
|
+
this.releaseNotice(`task ${this.publicTitle(stale)}: could not verify assignment: ${e instanceof Error ? e.message : String(e)}`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
945
971
|
}
|
|
946
972
|
|
|
947
|
-
private mine(task: Task, by: PeerId, role: "owner" | "reviewer"): void {
|
|
948
|
-
if (by !== USER && task[role] !== by) throw new Error(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
|
|
973
|
+
private mine(task: Task, by: PeerId, role: "owner" | "reviewer", preEffect = false): void {
|
|
974
|
+
if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
|
|
949
975
|
}
|
|
950
976
|
|
|
951
|
-
private need(id: unknown, open = false): Task {
|
|
977
|
+
private need(id: unknown, open = false, preEffect = false): Task {
|
|
952
978
|
// Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
|
|
953
979
|
const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
|
|
954
|
-
if (!Number.isInteger(n)) throw new Error(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
|
|
980
|
+
if (!Number.isInteger(n)) throw new (preEffect ? PreEffectToolRefusal : Error)(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
|
|
955
981
|
const task = this.d.board.get(n);
|
|
956
|
-
if (!task) throw new Error(`no task #${n}`);
|
|
982
|
+
if (!task) throw new (preEffect ? PreEffectToolRefusal : Error)(`no task #${n}`);
|
|
957
983
|
// Handing a task to someone else only makes sense while there is work left on it.
|
|
958
|
-
if (open && !OPEN.includes(task.state)) throw new Error(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
984
|
+
if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
959
985
|
return task;
|
|
960
986
|
}
|
|
961
987
|
|
|
@@ -964,15 +990,16 @@ export class Tasks {
|
|
|
964
990
|
* announced like one found at assignment (issue #31).
|
|
965
991
|
*/
|
|
966
992
|
accept(by: PeerId, id: unknown, plan?: unknown): Task {
|
|
967
|
-
const task = this.need(id);
|
|
968
|
-
this.mine(task, by, "owner");
|
|
969
|
-
this.ready(task);
|
|
993
|
+
const task = this.need(id, false, true);
|
|
994
|
+
this.mine(task, by, "owner", true);
|
|
995
|
+
this.ready(task, true);
|
|
996
|
+
if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
|
|
970
997
|
// Models send null or {} for an optional field they leave empty: neither replaces a plan.
|
|
971
998
|
const given = plan == null ? undefined : cleanPlan(plan);
|
|
972
999
|
const cleaned = given && Object.keys(given).length ? given : undefined;
|
|
973
1000
|
// A task's signals are fixed when it is proposed: text that matches a PII pattern cannot be let in afterwards.
|
|
974
1001
|
if (cleaned && !this.isPii(task) && this.isPii({ signals: detectSignals({ title: "", detail: planText(cleaned), refs: {} }, this.d.routing(), this.d.cwd) })) {
|
|
975
|
-
throw new
|
|
1002
|
+
throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
|
|
976
1003
|
}
|
|
977
1004
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
978
1005
|
const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}) });
|
|
@@ -1031,8 +1058,8 @@ export class Tasks {
|
|
|
1031
1058
|
};
|
|
1032
1059
|
|
|
1033
1060
|
async decline(by: PeerId, id: unknown, reason?: string): Promise<Task> {
|
|
1034
|
-
const task = this.need(id, true);
|
|
1035
|
-
this.mine(task, by, "owner");
|
|
1061
|
+
const task = this.need(id, true, true);
|
|
1062
|
+
this.mine(task, by, "owner", true);
|
|
1036
1063
|
// The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
|
|
1037
1064
|
const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason, task.owner ? { from: task.owner } : {});
|
|
1038
1065
|
this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
@@ -1051,13 +1078,13 @@ export class Tasks {
|
|
|
1051
1078
|
|
|
1052
1079
|
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
|
|
1053
1080
|
const valid = (): Task => {
|
|
1054
|
-
const t = this.need(id);
|
|
1055
|
-
this.mine(t, by, "owner");
|
|
1056
|
-
if (t.state === "in_review" || t.state === "approved") throw new
|
|
1057
|
-
this.ready(t);
|
|
1081
|
+
const t = this.need(id, false, true);
|
|
1082
|
+
this.mine(t, by, "owner", true);
|
|
1083
|
+
if (t.state === "in_review" || t.state === "approved") throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
|
|
1084
|
+
this.ready(t, true);
|
|
1058
1085
|
if (this.checking.has(t.id)) {
|
|
1059
1086
|
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
1060
|
-
throw new
|
|
1087
|
+
throw new PreEffectToolRefusal(this.checking.get(t.id) === t.owner ? `task #${t.id}: its check is still running; its result comes as a task message` : `task #${t.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
|
|
1061
1088
|
}
|
|
1062
1089
|
return t;
|
|
1063
1090
|
};
|
|
@@ -1306,10 +1333,10 @@ export class Tasks {
|
|
|
1306
1333
|
|
|
1307
1334
|
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
|
|
1308
1335
|
const valid = (): Task => {
|
|
1309
|
-
const t = this.need(id);
|
|
1310
|
-
this.mine(t, by, "reviewer");
|
|
1311
|
-
if (verdict !== "approved" && verdict !== "changes_requested") throw new
|
|
1312
|
-
if (t.state !== "in_review") throw new
|
|
1336
|
+
const t = this.need(id, false, true);
|
|
1337
|
+
this.mine(t, by, "reviewer", true);
|
|
1338
|
+
if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
|
|
1339
|
+
if (t.state !== "in_review") throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
|
|
1313
1340
|
return t;
|
|
1314
1341
|
};
|
|
1315
1342
|
const items = textList(unmet);
|
|
@@ -1352,8 +1379,8 @@ export class Tasks {
|
|
|
1352
1379
|
}
|
|
1353
1380
|
|
|
1354
1381
|
/** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
|
|
1355
|
-
async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual"): Promise<Task> {
|
|
1356
|
-
let task = this.need(id, true);
|
|
1382
|
+
async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
|
|
1383
|
+
let task = this.need(id, true, preEffect);
|
|
1357
1384
|
const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
|
|
1358
1385
|
if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
|
|
1359
1386
|
const from = task.owner;
|
|
@@ -1372,8 +1399,8 @@ export class Tasks {
|
|
|
1372
1399
|
}
|
|
1373
1400
|
|
|
1374
1401
|
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
1375
|
-
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER): Promise<Task> {
|
|
1376
|
-
const task = this.need(id, true);
|
|
1402
|
+
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false): Promise<Task> {
|
|
1403
|
+
const task = this.need(id, true, preEffect);
|
|
1377
1404
|
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
1378
1405
|
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], event: "reassigned", reason: "manual", override: by === USER });
|
|
1379
1406
|
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A hub guard proved this request was refused before any task or board effect (screen telemetry is allowed). Never wrap arbitrary failures in this type. */
|
|
2
|
+
export class PreEffectToolRefusal extends Error {
|
|
3
|
+
constructor(message: string) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "PreEffectToolRefusal";
|
|
6
|
+
}
|
|
7
|
+
}
|
package/src/hub/ui.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { taskProgress } from "../ui/task-progress.ts";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { readFileSync } from "node:fs";
|
|
3
4
|
|
|
@@ -20,7 +21,9 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
20
21
|
const now = options.now ?? Date.now;
|
|
21
22
|
const tickets = new Map<string, number>();
|
|
22
23
|
const sessions = new Map<string, number>();
|
|
23
|
-
|
|
24
|
+
// Inject the same pure public-board model used by the console before computing CSP hashes.
|
|
25
|
+
const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
|
|
26
|
+
.replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`);
|
|
24
27
|
const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
|
|
25
28
|
.map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");
|
|
26
29
|
const headers = {
|
package/src/local/tools.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join, relative, resolve } from "node:path";
|
|
3
3
|
import { realPath } from "../hub/project.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { isHubWritePath } from "../hub/permission-mode.ts";
|
|
5
|
+
import { isDenied } from "./deny.ts";
|
|
5
6
|
import { OUTPUT_CAP, sandboxedExec } from "./sandbox.ts";
|
|
6
7
|
|
|
7
8
|
export { isDenied };
|
|
@@ -17,14 +18,11 @@ export interface ToolContext {
|
|
|
17
18
|
/** Turn cancellation signal, including the execution-budget wall cap. */
|
|
18
19
|
signal?: AbortSignal;
|
|
19
20
|
/** Ask the console. Resolves false on deny or timeout. */
|
|
20
|
-
permit: (title: string) => Promise<boolean>;
|
|
21
|
+
permit: (title: string, tool?: string, signal?: AbortSignal, path?: string) => Promise<boolean>;
|
|
21
22
|
/** Publish a message to other peers mid-turn. Returns a one-line receipt. */
|
|
22
23
|
send: (text: string, to?: string[]) => string;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
/** Not secret, but writing them changes what runs outside the worker's control. */
|
|
26
|
-
const WRITE_DENY_SEGMENTS = [".git", ".agenthub"];
|
|
27
|
-
|
|
28
26
|
const lexists = (p: string) => {
|
|
29
27
|
try {
|
|
30
28
|
lstatSync(p);
|
|
@@ -52,7 +50,9 @@ export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string,
|
|
|
52
50
|
const rel = relative(root, real);
|
|
53
51
|
if (rel.startsWith("..") || resolve(root, rel) !== real) throw new Error(`${path} is outside the project directory`);
|
|
54
52
|
if (isDenied(rel, ctx.deny)) throw new Error(`${path} is on the secrets denylist`);
|
|
55
|
-
|
|
53
|
+
// Not secret, but writing .git or .agenthub changes what runs outside the worker's control. Names are compared
|
|
54
|
+
// folded: a new `.GIT/config` is `.git/config` to a case-insensitive disk, and git then runs what it names.
|
|
55
|
+
if (mode === "write" && isHubWritePath(rel)) throw new Error(`${path} is not writable by the local worker`);
|
|
56
56
|
return real;
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -129,23 +129,27 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
129
129
|
}
|
|
130
130
|
case "write": {
|
|
131
131
|
let file = guardPath(ctx, String(a.path), "write");
|
|
132
|
+
const approvedTarget = file;
|
|
132
133
|
const content = String(a.content ?? "");
|
|
133
|
-
if (!(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}
|
|
134
|
+
if (!(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file))) return "error: the user did not approve this write";
|
|
134
135
|
if (ctx.signal?.aborted) return "error: turn cancelled before write";
|
|
135
136
|
file = guardPath(ctx, String(a.path), "write");
|
|
137
|
+
if (file !== approvedTarget) return "error: path target changed during approval; inspect before retrying";
|
|
136
138
|
mkdirSync(dirname(file), { recursive: true });
|
|
137
139
|
writeFileSync(file, content);
|
|
138
140
|
return `wrote ${a.path}`;
|
|
139
141
|
}
|
|
140
142
|
case "edit": {
|
|
141
143
|
let file = guardPath(ctx, String(a.path), "write");
|
|
144
|
+
const approvedTarget = file;
|
|
142
145
|
let text = readFileSync(file, "utf8");
|
|
143
146
|
let count = text.split(String(a.old)).length - 1;
|
|
144
147
|
if (!a.old || count !== 1) return `error: \`old\` must match exactly once, it matched ${count} times`;
|
|
145
|
-
if (!(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}
|
|
148
|
+
if (!(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file))) return "error: the user did not approve this edit";
|
|
146
149
|
if (ctx.signal?.aborted) return "error: turn cancelled before edit";
|
|
147
150
|
// Approval can outlive another peer's edit or a path change. Apply only the approved fragment to current bytes.
|
|
148
151
|
file = guardPath(ctx, String(a.path), "write");
|
|
152
|
+
if (file !== approvedTarget) return "error: path target changed during approval; inspect before retrying";
|
|
149
153
|
text = readFileSync(file, "utf8");
|
|
150
154
|
count = text.split(String(a.old)).length - 1;
|
|
151
155
|
if (count !== 1) return `error: file changed during approval; \`old\` must match exactly once, it matched ${count} times`;
|
|
@@ -157,7 +161,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
157
161
|
if (!command.trim()) return "error: empty command";
|
|
158
162
|
// The approver sees the whole command, not a prefix: what is hidden cannot be approved.
|
|
159
163
|
if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
|
|
160
|
-
if (!(await ctx.permit(`bash: ${command}
|
|
164
|
+
if (!(await ctx.permit(`bash: ${command}`, "bash", ctx.signal))) return "error: the user did not approve this command";
|
|
161
165
|
const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
162
166
|
return `${res.output}\n(exit ${res.code})`;
|
|
163
167
|
}
|
|
@@ -167,7 +171,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
167
171
|
if (!GIT_READ.has(sub) && !GIT_WRITE.has(sub)) return `error: git ${sub} is not available to the local worker`;
|
|
168
172
|
const problem = gitArgsProblem(args, ctx.deny);
|
|
169
173
|
if (problem) return `error: git ${sub}: ${problem}`;
|
|
170
|
-
if (GIT_WRITE.has(sub) && !(await ctx.permit(`git ${args.join(" ")}
|
|
174
|
+
if (GIT_WRITE.has(sub) && !(await ctx.permit(`git ${args.join(" ")}`, "git", ctx.signal))) return "error: the user did not approve this git command";
|
|
171
175
|
// Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
|
|
172
176
|
const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
173
177
|
return `${res.output}\n(exit ${res.code})`;
|
|
@@ -17,3 +17,26 @@ export function processSignature(pid: number): string | undefined {
|
|
|
17
17
|
return text ? createHash("sha256").update(text).digest("hex") : undefined;
|
|
18
18
|
} catch { return undefined; }
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
export type Liveness = "live" | "gone" | "unknown";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* #226: the one ownership check behind every recorded pid (hub manifest, registry claim, recovery runner, manager
|
|
25
|
+
* owner, launcher records). With a recorded `signature` the identity decides: equal is live, different is gone (the
|
|
26
|
+
* pid was reused, after a reboot for example, also by a process we may not signal), and a pid that exists but whose
|
|
27
|
+
* identity cannot be read is unknown. Without one (records written by 0.12.20 and older) the pid probe answers as it
|
|
28
|
+
* always did: live, gone on ESRCH, unknown otherwise. Unknown is never gone.
|
|
29
|
+
*/
|
|
30
|
+
// ponytail: `ps lstart` is derived from boot time plus the process's age; a wall-clock step on Linux can change it for a
|
|
31
|
+
// live process, which then reads as gone (a second hub could take its claim). Upgrade path: a boot-id or start-tick
|
|
32
|
+
// (Linux /proc/<pid>/stat field 22) component in the signature.
|
|
33
|
+
export function processLiveness(pid: unknown, signature?: string | null, identity: (pid: number) => string | undefined = processSignature): Liveness {
|
|
34
|
+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return "unknown";
|
|
35
|
+
if (signature) {
|
|
36
|
+
let current: string | undefined;
|
|
37
|
+
try { current = identity(pid); } catch { /* unreadable: the pid probe below decides between gone and unknown */ }
|
|
38
|
+
if (current) return current === signature ? "live" : "gone";
|
|
39
|
+
}
|
|
40
|
+
try { process.kill(pid, 0); return signature ? "unknown" : "live"; }
|
|
41
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? "gone" : "unknown"; }
|
|
42
|
+
}
|