@staix/agent-hub 0.8.1 → 0.10.0
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 +16 -0
- package/README.md +2 -2
- package/docs/events.md +2 -1
- package/docs/operations.md +185 -11
- package/docs/quickstart.md +1 -1
- package/docs/security.md +4 -1
- package/docs/smoke.md +54 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +171 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5 -5
- package/src/adapters/acp.ts +10 -2
- package/src/adapters/local-worker.ts +4 -3
- package/src/cli/main.ts +49 -12
- package/src/cli/upgrade.ts +1 -1
- package/src/hub/board.ts +50 -7
- package/src/hub/budget.ts +57 -6
- package/src/hub/bus.ts +24 -6
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/conflicts.ts +63 -0
- package/src/hub/crash.ts +104 -0
- package/src/hub/daemon.ts +227 -30
- package/src/hub/events.ts +1 -0
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/limits.ts +71 -0
- package/src/hub/peers.ts +3 -2
- package/src/hub/report.ts +7 -2
- package/src/hub/routing.ts +59 -4
- package/src/hub/snapshots.ts +29 -2
- package/src/hub/tasks.ts +204 -14
- package/src/local/sandbox.ts +60 -9
- package/templates/claude-hooks.json +10 -0
- package/templates/config.json +7 -3
package/src/hub/tasks.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Briefs } from "../memory/brief.ts";
|
|
2
2
|
import type { MemoryClient } from "../memory/client.ts";
|
|
3
|
-
import { CLASSES, PLAN_KEYS, type Board, type Task, type TaskClass, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
3
|
+
import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, type Board, type Task, type TaskClass, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
4
4
|
import type { Bus } from "./bus.ts";
|
|
5
5
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
6
6
|
import { assign, detectSignals, LOCAL, PI, type Assignment, type Routing } from "./routing.ts";
|
|
@@ -25,17 +25,28 @@ export interface TasksDeps {
|
|
|
25
25
|
tell?: (peer: PeerId, line: string) => void;
|
|
26
26
|
/** Structured overlap records for telemetry (issue #40); the notice line stays for the human. */
|
|
27
27
|
recordOverlap?: (task: number, owner: PeerId, others: { task: number; owner: PeerId; paths: string[]; symbols?: string[] }[]) => void;
|
|
28
|
+
/** Quota per peer from fresh readings (issue #36): routing drains the windows that reset soonest first. */
|
|
29
|
+
quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
|
|
30
|
+
/** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
|
|
31
|
+
review?: { adaptive: boolean; min_reviews: number };
|
|
28
32
|
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
29
33
|
triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
const ESCALATE_AFTER = 2;
|
|
37
|
+
// Demotion (issue #36): a failure counts half after a day. A peer is demoted for a class once its decayed failures
|
|
38
|
+
// reach 1.5 (two within about half a day, three within two days) and outweigh its decayed successes there.
|
|
39
|
+
// ponytail: fixed constants; make them config when someone needs to tune them.
|
|
40
|
+
const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
|
|
41
|
+
const DEMOTE_AT = 1.5;
|
|
42
|
+
/** An approval counts as contradicted when work on the same places fails within this window (issue #35). */
|
|
43
|
+
const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
33
44
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
34
45
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
35
46
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
|
|
36
47
|
|
|
37
48
|
/** Same path, or one is a directory of the other; the project root (`.`) holds everything. */
|
|
38
|
-
const samePlace = (a: string, b: string) => {
|
|
49
|
+
export const samePlace = (a: string, b: string) => {
|
|
39
50
|
const norm = (p: string) => p.replace(/^\.\//, "").replace(/\/+$/, "") || ".";
|
|
40
51
|
const [x, y] = [norm(a), norm(b)];
|
|
41
52
|
return x === "." || y === "." || x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
|
|
@@ -99,16 +110,102 @@ export class Tasks {
|
|
|
99
110
|
return Object.fromEntries([...this.d.bus.peers.keys()].map((id) => [id, this.d.bus.stateOf(id)]));
|
|
100
111
|
}
|
|
101
112
|
|
|
102
|
-
|
|
113
|
+
/** Dependencies of a task that are not approved yet: while there are any, it is offered to nobody (issue #34). */
|
|
114
|
+
waitsFor = (task: Pick<Task, "deps">): number[] => (task.deps ?? []).filter((id) => this.d.board.get(id)?.state !== "approved");
|
|
115
|
+
|
|
116
|
+
/** Decayed failure weights of the peers demoted for a class: failures count half after a day (issue #36). */
|
|
117
|
+
demoted(cls: TaskClass, now = Date.now()): Record<PeerId, number> {
|
|
118
|
+
const sums = new Map<PeerId, { bad: number; good: number }>();
|
|
119
|
+
for (const o of this.d.board.outcomes(cls, now - OUTCOMES_KEPT_MS)) {
|
|
120
|
+
const w = 0.5 ** ((now - o.at) / DEMOTION_HALF_LIFE_MS);
|
|
121
|
+
const s = sums.get(o.peer) ?? { bad: 0, good: 0 };
|
|
122
|
+
if (o.ok) s.good += w;
|
|
123
|
+
else s.bad += w;
|
|
124
|
+
sums.set(o.peer, s);
|
|
125
|
+
}
|
|
126
|
+
return Object.fromEntries([...sums].filter(([, s]) => s.bad >= DEMOTE_AT && s.bad > s.good).map(([p, s]) => [p, s.bad]));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** How each reviewer's reviews of each implementer's work in a class held up (issue #35). */
|
|
130
|
+
reviewRecord(cls: TaskClass): Record<PeerId, Record<PeerId, { score: number; n: number }>> {
|
|
131
|
+
// Counted per task: a review that asked for changes and then approved is one review, not two.
|
|
132
|
+
const c = new Map<string, { reviewed: Set<number>; contradicted: Set<number> }>();
|
|
133
|
+
for (const r of this.d.board.reviews({ class: cls })) {
|
|
134
|
+
const key = `${r.implementer}\0${r.reviewer}`;
|
|
135
|
+
const s = c.get(key) ?? { reviewed: new Set<number>(), contradicted: new Set<number>() };
|
|
136
|
+
(r.kind === "contradicted" ? s.contradicted : s.reviewed).add(r.task);
|
|
137
|
+
c.set(key, s);
|
|
138
|
+
}
|
|
139
|
+
const out: Record<PeerId, Record<PeerId, { score: number; n: number }>> = {};
|
|
140
|
+
for (const [key, s] of c) {
|
|
141
|
+
const [implementer, reviewer] = key.split("\0") as [PeerId, PeerId];
|
|
142
|
+
const n = s.reviewed.size;
|
|
143
|
+
if (n) (out[implementer] ??= {})[reviewer] = { score: (n - [...s.contradicted].filter((t) => s.reviewed.has(t)).length) / n, n };
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Who asked for changes on the current owner's work: requests made before the task changed hands were about someone else's. */
|
|
149
|
+
private requestedChanges(task: Task): Set<PeerId> {
|
|
150
|
+
const since = task.history.findLastIndex((h) => ["assigned", "escalated", "reassigned", "unassigned"].includes(h.event));
|
|
151
|
+
return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** What assignment weighs besides states and policy: quota, demotion and the review record. */
|
|
155
|
+
private weights(cls: TaskClass) {
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
const quota = this.d.quota?.();
|
|
158
|
+
return { now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Work on these places failed (a check failure, or changes requested): approvals of other tasks on the same places
|
|
163
|
+
* within the window were contradicted. Each approval counts once.
|
|
164
|
+
*/
|
|
165
|
+
private contradict(failed: Task): void {
|
|
166
|
+
if (this.isPii(failed)) return; // its places are left out everywhere else too
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
const mine = this.places(failed);
|
|
169
|
+
for (const t of this.d.board.list("approved")) {
|
|
170
|
+
if (t.id === failed.id || !t.owner || this.isPii(t)) continue;
|
|
171
|
+
const approval = [...t.history].reverse().find((h) => h.event === "approved");
|
|
172
|
+
if (!approval || now - approval.at > CONTRADICTION_WINDOW_MS || approval.by === USER) continue;
|
|
173
|
+
const theirs = this.places(t);
|
|
174
|
+
// Blame needs the same file or symbol: a directory or `.` would contradict every approval under it.
|
|
175
|
+
const same = mine.paths.some((p) => theirs.paths.includes(p)) || mine.symbols.some((x) => theirs.symbols.includes(x));
|
|
176
|
+
if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
|
|
177
|
+
this.d.board.recordReview({ implementer: t.owner, reviewer: approval.by, class: t.class, kind: "contradicted", task: t.id });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown }): Promise<Task> {
|
|
103
182
|
// Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
|
|
104
183
|
const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
105
184
|
if (!title) throw new Error("title is required");
|
|
185
|
+
// Dependencies can only name tasks that exist, so the new task closes no cycle: nothing can depend on it yet.
|
|
186
|
+
// Callers are models: a missing or null `after` is no dependency, and an id is an integer or a digit string.
|
|
187
|
+
let afterIds: unknown[] = [];
|
|
188
|
+
if (Array.isArray(input.after)) afterIds = input.after;
|
|
189
|
+
else if (input.after != null) afterIds = [input.after];
|
|
190
|
+
const deps: number[] = [];
|
|
191
|
+
for (const v of afterIds) {
|
|
192
|
+
const id = typeof v === "number" || (typeof v === "string" && /^\s*\d+\s*$/.test(v)) ? Number(v) : NaN;
|
|
193
|
+
if (!Number.isInteger(id)) throw new Error(`after: ${JSON.stringify(v)} is not a task id`);
|
|
194
|
+
if (!this.d.board.get(id)) throw new Error(`after: no task #${id}`);
|
|
195
|
+
if (!deps.includes(id)) deps.push(id);
|
|
196
|
+
}
|
|
197
|
+
const waits = this.waitsFor({ deps });
|
|
198
|
+
if (waits.length && input.owner) {
|
|
199
|
+
throw new Error(`this task would wait for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet: ${input.owner === by ? "claim it once they are" : "propose it without an owner; routing offers it when they are approved"}`);
|
|
200
|
+
}
|
|
106
201
|
const given = input.class === undefined || input.class === "" ? undefined : input.class;
|
|
107
202
|
if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new Error(`class must be one of ${CLASSES.join(", ")}`);
|
|
108
203
|
const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
|
|
109
204
|
const plan = cleanPlan(input.plan);
|
|
110
205
|
// The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
|
|
111
206
|
const signals = detectSignals({ ...text, detail: [text.detail, planText(plan)].join("\n") }, this.d.routing(), this.d.cwd);
|
|
207
|
+
// Urgent work is handed over even when its paused owner's window resets soon (issue #36).
|
|
208
|
+
if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
|
|
112
209
|
let cls = given as TaskClass | undefined;
|
|
113
210
|
let triaged = false;
|
|
114
211
|
if (!cls && this.d.triage) {
|
|
@@ -122,10 +219,16 @@ export class Tasks {
|
|
|
122
219
|
if (defaulted) cls = "implement";
|
|
123
220
|
if (!cls) throw new Error(`class is required (one of ${CLASSES.join(", ")}); the hub could not name one for you`);
|
|
124
221
|
const draft = { ...text, class: cls };
|
|
125
|
-
let task = this.d.board.propose(by, { ...draft, plan, signals });
|
|
222
|
+
let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), signals });
|
|
126
223
|
if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
|
|
127
224
|
if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
|
|
128
225
|
this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${task.class}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
|
|
226
|
+
// Re-read: a dependency approved while triage was awaited looked for its dependents before this row existed.
|
|
227
|
+
const still = this.waitsFor(task);
|
|
228
|
+
if (still.length) {
|
|
229
|
+
this.d.notify(`task ${this.publicTitle(task)} waits for ${still.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
230
|
+
return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
231
|
+
}
|
|
129
232
|
// Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
|
|
130
233
|
return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], claim: input.owner === by } : {});
|
|
131
234
|
}
|
|
@@ -146,7 +249,7 @@ export class Tasks {
|
|
|
146
249
|
private releasing: PeerId | undefined;
|
|
147
250
|
|
|
148
251
|
/** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
|
|
149
|
-
|
|
252
|
+
nameable = (t: string): boolean => !this.isPii({ signals: detectSignals({ title: "", detail: t, refs: {} }, this.d.routing(), this.d.cwd) });
|
|
150
253
|
|
|
151
254
|
/** Where two tasks meet: shared paths, then shared symbols, leaving out any name that matches a PII pattern. */
|
|
152
255
|
private where(hit: { paths: string[]; symbols: string[] }): string {
|
|
@@ -227,16 +330,21 @@ export class Tasks {
|
|
|
227
330
|
if (typeof target === "number") {
|
|
228
331
|
const task = this.d.board.get(target);
|
|
229
332
|
if (!task) throw new Error(`no task #${target}`);
|
|
230
|
-
return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"})`, "if it were assigned now:", ...assign(task, this.states(), routing, { exclude: this.declined(task) }).trace];
|
|
333
|
+
return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"})`, "if it were assigned now:", ...assign(task, this.states(), routing, { exclude: this.declined(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).trace];
|
|
231
334
|
}
|
|
232
335
|
const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
|
|
233
|
-
return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing).trace;
|
|
336
|
+
return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing, this.weights(target.class)).trace;
|
|
234
337
|
}
|
|
235
338
|
|
|
236
339
|
private declined = (task: Task) => task.history.filter((h) => h.event === "declined").map((h) => h.by);
|
|
237
340
|
|
|
238
341
|
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; claim?: boolean } = {}): Promise<Task> {
|
|
239
|
-
const
|
|
342
|
+
const waits = this.waitsFor(task);
|
|
343
|
+
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class) });
|
|
344
|
+
if (waits.length) {
|
|
345
|
+
this.d.notify(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
346
|
+
return task;
|
|
347
|
+
}
|
|
240
348
|
if (!a.owner) {
|
|
241
349
|
this.d.notify(`task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
|
|
242
350
|
// Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
|
|
@@ -278,6 +386,46 @@ export class Tasks {
|
|
|
278
386
|
this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner!], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) }));
|
|
279
387
|
}
|
|
280
388
|
|
|
389
|
+
/** Nobody, the console user included, works on a task before what it waits for is approved. */
|
|
390
|
+
private ready(task: Task): void {
|
|
391
|
+
const waits = this.waitsFor(task);
|
|
392
|
+
if (waits.length) throw new Error(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
|
|
396
|
+
private async releaseDependents(approved: Task): Promise<void> {
|
|
397
|
+
for (const t of this.d.board.list("proposed")) {
|
|
398
|
+
if (!t.deps?.includes(approved.id) || t.owner || this.waitsFor(t).length) continue;
|
|
399
|
+
await this.offerReady(t, `#${approved.id} approved`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private readonly offered = new Set<number>(); // ready tasks offered in this hub run
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* A stop between an approval and the assignment of its dependents (both are saved on their own) leaves them ownerless
|
|
407
|
+
* with nothing left to wait for, and no later approval to offer them. The daemon calls this on its release timer:
|
|
408
|
+
* each such task is offered once per hub run, once an attached peer can take it (peers attach one by one).
|
|
409
|
+
*/
|
|
410
|
+
async releaseReady(): Promise<void> {
|
|
411
|
+
for (const t of this.d.board.list("proposed")) {
|
|
412
|
+
const last = t.history.at(-1)?.event;
|
|
413
|
+
if (!t.deps?.length || t.owner || this.offered.has(t.id) || (last !== "blocked" && last !== "ready") || this.waitsFor(t).length) continue;
|
|
414
|
+
if (!assign(t, this.states(), this.d.routing(), { exclude: this.declined(t) }).owner) continue; // nobody attached can take it yet
|
|
415
|
+
await this.offerReady(t, "nothing left to wait for");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
|
|
420
|
+
private async offerReady(stale: Task, why: string): Promise<void> {
|
|
421
|
+
const t = this.d.board.get(stale.id);
|
|
422
|
+
if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
|
|
423
|
+
this.offered.add(t.id);
|
|
424
|
+
const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
|
|
425
|
+
this.d.notify(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
|
|
426
|
+
await this.assignOwner(ready, HUB).catch((e: Error) => this.d.notify(`task ${this.publicTitle(ready)}: could not be assigned: ${e.message}`));
|
|
427
|
+
}
|
|
428
|
+
|
|
281
429
|
private mine(task: Task, by: PeerId, role: "owner" | "reviewer"): void {
|
|
282
430
|
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`);
|
|
283
431
|
}
|
|
@@ -297,6 +445,7 @@ export class Tasks {
|
|
|
297
445
|
accept(by: PeerId, id: unknown, plan?: unknown): Task {
|
|
298
446
|
const task = this.need(id);
|
|
299
447
|
this.mine(task, by, "owner");
|
|
448
|
+
this.ready(task);
|
|
300
449
|
// Models send null or {} for an optional field they leave empty: neither replaces a plan.
|
|
301
450
|
const given = plan == null ? undefined : cleanPlan(plan);
|
|
302
451
|
const cleaned = given && Object.keys(given).length ? given : undefined;
|
|
@@ -327,6 +476,9 @@ export class Tasks {
|
|
|
327
476
|
/** Tasks whose check is queued or running, and the owner it was started for. */
|
|
328
477
|
private readonly checking = new Map<number, PeerId | null>();
|
|
329
478
|
private checkQueue: Promise<void> = Promise.resolve();
|
|
479
|
+
private pendingChecks = 0;
|
|
480
|
+
/** Checks queued or running, until their result is on the board: a recovery commit waits for them. */
|
|
481
|
+
checksPending = () => this.pendingChecks;
|
|
330
482
|
|
|
331
483
|
/** Whether a completion check is still running for this task. */
|
|
332
484
|
isChecking = (id: number) => this.checking.has(id);
|
|
@@ -335,6 +487,7 @@ export class Tasks {
|
|
|
335
487
|
let task = this.need(id);
|
|
336
488
|
this.mine(task, by, "owner");
|
|
337
489
|
if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
|
|
490
|
+
this.ready(task);
|
|
338
491
|
if (this.checking.has(task.id)) {
|
|
339
492
|
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
340
493
|
throw new Error(this.checking.get(task.id) === task.owner ? `task #${task.id}: its check is still running; its result comes as a task message` : `task #${task.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
|
|
@@ -347,9 +500,11 @@ export class Tasks {
|
|
|
347
500
|
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary);
|
|
348
501
|
this.d.notify(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
349
502
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
503
|
+
this.pendingChecks++;
|
|
350
504
|
this.checkQueue = this.checkQueue
|
|
351
505
|
.then(() => this.finishChecked(pending.id, by, summary, command, seen))
|
|
352
|
-
.catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`))
|
|
506
|
+
.catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`))
|
|
507
|
+
.finally(() => this.pendingChecks--);
|
|
353
508
|
return pending;
|
|
354
509
|
}
|
|
355
510
|
|
|
@@ -375,6 +530,8 @@ export class Tasks {
|
|
|
375
530
|
return;
|
|
376
531
|
}
|
|
377
532
|
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
|
|
533
|
+
if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
|
|
534
|
+
this.contradict(task);
|
|
378
535
|
this.d.notify(`task ${this.publicTitle(task)}: its check failed (${outcome}); it stays with ${task.owner ?? by}`);
|
|
379
536
|
this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
|
|
380
537
|
}
|
|
@@ -384,7 +541,11 @@ export class Tasks {
|
|
|
384
541
|
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary);
|
|
385
542
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`);
|
|
386
543
|
this.tellCompleted(next, summary);
|
|
387
|
-
if (!reviewer)
|
|
544
|
+
if (!reviewer) {
|
|
545
|
+
if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
|
|
546
|
+
this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
547
|
+
await this.releaseDependents(next);
|
|
548
|
+
}
|
|
388
549
|
else if (reviewer === USER) this.d.notify(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
|
|
389
550
|
else this.sendReview(next, reviewer);
|
|
390
551
|
return next;
|
|
@@ -420,23 +581,45 @@ export class Tasks {
|
|
|
420
581
|
const r = task.refs;
|
|
421
582
|
const last = [...task.history].reverse().find((h) => h.event === "done");
|
|
422
583
|
const where = [r.branch ? `branch ${r.branch}` : "", r.commit ? `commit ${r.commit}` : "", r.paths?.length ? `paths ${r.paths.join(", ")}` : ""].filter(Boolean).join("; ");
|
|
423
|
-
|
|
584
|
+
// A checklist that maps the change to its contract (issue #35): reviewers who check against the written plan catch more.
|
|
585
|
+
const plan = planText(task.plan);
|
|
586
|
+
const check = [...task.history].reverse().find((h) => h.event === "check passed");
|
|
587
|
+
const checklist = [
|
|
588
|
+
"Checklist:",
|
|
589
|
+
`- Map the changed signatures and call sites to ${plan ? `the plan (${plan})` : task.detail ? "the task detail above" : "the task title"}, and name each one that does not match.`,
|
|
590
|
+
`- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
|
|
591
|
+
`- List what is unmet in hub_review's unmet, one item each.`,
|
|
592
|
+
].join("\n");
|
|
593
|
+
const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${last?.note ?? "(no summary)"}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
|
|
424
594
|
this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
425
595
|
}
|
|
426
596
|
|
|
427
|
-
async review(by: PeerId, id: unknown, verdict: unknown, note?: string): Promise<Task> {
|
|
597
|
+
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
|
|
428
598
|
const task = this.need(id);
|
|
429
599
|
this.mine(task, by, "reviewer");
|
|
430
600
|
if (verdict !== "approved" && verdict !== "changes_requested") throw new Error('verdict must be "approved" or "changes_requested"');
|
|
431
601
|
if (task.state !== "in_review") throw new Error(`task #${task.id} is ${task.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
|
|
432
602
|
const pii = this.isPii(task);
|
|
603
|
+
const items = textList(unmet);
|
|
604
|
+
if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
|
|
433
605
|
if (verdict === "approved") {
|
|
434
606
|
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note);
|
|
607
|
+
if (next.owner) {
|
|
608
|
+
this.d.board.recordOutcome(next.owner, next.class, true);
|
|
609
|
+
this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
|
|
610
|
+
// Changes requested on this owner's work and the redo passed: those reviews caught something.
|
|
611
|
+
for (const r of this.requestedChanges(task)) {
|
|
612
|
+
this.d.board.recordReview({ implementer: next.owner, reviewer: r, class: next.class, kind: "caught", task: next.id });
|
|
613
|
+
}
|
|
614
|
+
}
|
|
435
615
|
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`);
|
|
436
616
|
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${note}` : ""}`, pii);
|
|
617
|
+
await this.releaseDependents(next);
|
|
437
618
|
return next;
|
|
438
619
|
}
|
|
439
620
|
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note);
|
|
621
|
+
if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
|
|
622
|
+
this.contradict(rejected);
|
|
440
623
|
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);
|
|
441
624
|
if (rejected.rejections >= ESCALATE_AFTER) {
|
|
442
625
|
const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`);
|
|
@@ -456,6 +639,11 @@ export class Tasks {
|
|
|
456
639
|
const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
|
|
457
640
|
if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
|
|
458
641
|
const from = task.owner;
|
|
642
|
+
// The hub's own escalations are not counted: after repeated changes_requested each one already was, and after a
|
|
643
|
+
// Pi inference failure the backend failed, not the work. An escalation by hand counts on its own.
|
|
644
|
+
if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
|
|
645
|
+
// Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
|
|
646
|
+
if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
|
|
459
647
|
const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}`, context: why });
|
|
460
648
|
if (next.owner && next.owner !== from) {
|
|
461
649
|
this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
|
|
@@ -474,10 +662,12 @@ export class Tasks {
|
|
|
474
662
|
* Budget relay: a paused peer's open work moves on. `local` first (it has no quota), then the class list, all through
|
|
475
663
|
* the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
|
|
476
664
|
*/
|
|
477
|
-
async reassignForPause(peer: PeerId, context: string | undefined): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
665
|
+
async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
478
666
|
const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
|
|
479
667
|
const routing = this.d.routing();
|
|
480
668
|
for (const task of this.d.board.list()) {
|
|
669
|
+
// Waiting out a window that resets soon (issue #36): only urgent work moves.
|
|
670
|
+
if (urgentOnly && !task.signals.includes("urgent")) continue;
|
|
481
671
|
if (task.owner === peer && OPEN.includes(task.state)) {
|
|
482
672
|
const pii = this.isPii(task);
|
|
483
673
|
const candidates = [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p);
|
|
@@ -486,7 +676,7 @@ export class Tasks {
|
|
|
486
676
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
487
677
|
} else if (task.reviewer === peer && task.state !== "approved") {
|
|
488
678
|
// No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
|
|
489
|
-
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
|
|
679
|
+
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
490
680
|
const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
|
|
491
681
|
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, `budget pause of ${peer}`);
|
|
492
682
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
package/src/local/sandbox.ts
CHANGED
|
@@ -18,7 +18,19 @@ const q = sbplString;
|
|
|
18
18
|
* no network unless asked. In SBPL the last matching rule wins, so the denies come last.
|
|
19
19
|
*/
|
|
20
20
|
/** Toolchains and git identity: the only parts of the home directory a sandboxed command may read besides the project. */
|
|
21
|
-
const HOME_READABLE = [".bun", ".cargo", ".rustup", ".local", ".npm", ".cache", ".pyenv", ".nvm", ".deno", "go", ".gitconfig", ".config/git", "Library/Caches"];
|
|
21
|
+
const HOME_READABLE = [".bun", ".cargo", ".rustup", ".local", ".npm", ".cache", ".pyenv", ".nvm", ".deno", "go", ".gitconfig", ".config/git", "Library/Caches", ".volta", ".asdf", ".nodenv", ".rbenv", ".sdkman", "Library/Application Support/fnm", "Library/pnpm"];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Public CA bundles: the name denies (`*.pem` for keys) also match these, and TLS needs them once network is on.
|
|
25
|
+
* Allowed after the denies, by exact path.
|
|
26
|
+
*/
|
|
27
|
+
const CA_BUNDLES = ["/private/etc/ssl/cert.pem", "/opt/homebrew/etc/ca-certificates/cert.pem", "/opt/homebrew/etc/openssl@3/cert.pem", "/usr/local/etc/ca-certificates/cert.pem", "/usr/local/etc/openssl@3/cert.pem"];
|
|
28
|
+
|
|
29
|
+
/** The selected Xcode or Command Line Tools dir: the `/usr/bin` shims (git, clang, make, python3) run what is in it. */
|
|
30
|
+
function developerDir(): string | undefined {
|
|
31
|
+
const out = spawnSync("xcode-select", ["-p"], { encoding: "utf8" });
|
|
32
|
+
return out.status === 0 && out.stdout.trim() ? out.stdout.trim() : undefined;
|
|
33
|
+
}
|
|
22
34
|
|
|
23
35
|
/** A submodule or worktree keeps its git dir outside the project; git needs it, minus the parts that execute or reconfigure. */
|
|
24
36
|
function externalGitDirs(root: string): string[] {
|
|
@@ -28,28 +40,67 @@ function externalGitDirs(root: string): string[] {
|
|
|
28
40
|
return [...new Set(dirs)].filter((d) => !d.startsWith(`${root}/`));
|
|
29
41
|
}
|
|
30
42
|
|
|
31
|
-
|
|
43
|
+
/** The system directories a deny-default profile lets commands read and run from: dyld, frameworks, toolchains. */
|
|
44
|
+
const SYSTEM_READABLE = ["/usr", "/bin", "/sbin", "/System", "/Library", "/opt", "/private/etc", "/private/var/db/timezone", "/private/var/db/dyld"];
|
|
45
|
+
/**
|
|
46
|
+
* Directory lookups (users, groups), logging and notifications; plus name resolution and TLS trust when network is on.
|
|
47
|
+
* No brokers that act outside the sandbox: LaunchServices would let `open` start a browser with network, and
|
|
48
|
+
* SecurityServer would answer Keychain queries the credential-path denies are there to stop.
|
|
49
|
+
*/
|
|
50
|
+
const MACH_SERVICES = ["com.apple.system.opendirectoryd.libinfo", "com.apple.system.DirectoryService.libinfo_v1", "com.apple.system.logger", "com.apple.system.notification_center"];
|
|
51
|
+
const NETWORK_MACH_SERVICES = ["com.apple.dnssd.service", "com.apple.trustd", "com.apple.trustd.agent", "com.apple.networkd"];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `base` "deny" (issue #39) starts from `(deny default)` and allows only what commands need: running and reading the
|
|
55
|
+
* system, toolchain and project directories, writing the project and temp. "allow" is the profile of 0.9 and earlier,
|
|
56
|
+
* kept for one release behind `local.sandbox: "allow-default"`. The denies at the end apply to both.
|
|
57
|
+
*/
|
|
58
|
+
export function profile(cwd: string, network: boolean, readAllow: string[] = [], deny: string[] = [], base: "deny" | "allow" = "deny"): string {
|
|
32
59
|
const home = homedir();
|
|
33
60
|
const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
|
|
34
61
|
const root = realPath(cwd);
|
|
35
62
|
const tmp = realPath(tmpdir());
|
|
36
63
|
const gitDirs = externalGitDirs(root);
|
|
37
64
|
const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
|
|
65
|
+
const dev = developerDir();
|
|
66
|
+
const readable = [root, ...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs, ...(dev ? [dev] : [])];
|
|
67
|
+
const subpaths = (paths: string[]) => paths.map((p) => `(subpath ${q(p)})`).join(" ");
|
|
68
|
+
const globals = (names: string[]) => names.map((n) => `(global-name ${q(n)})`).join(" ");
|
|
69
|
+
const start = base === "deny"
|
|
70
|
+
? [
|
|
71
|
+
"(deny default)",
|
|
72
|
+
"(allow process-fork)",
|
|
73
|
+
// Runs from the system, toolchain and project directories only; a script runs through an allowed interpreter.
|
|
74
|
+
`(allow process-exec ${subpaths([...SYSTEM_READABLE, ...readable, tmp, "/private/tmp"])})`,
|
|
75
|
+
"(allow signal (target same-sandbox))",
|
|
76
|
+
"(allow process-info* (target same-sandbox))",
|
|
77
|
+
"(allow sysctl-read)",
|
|
78
|
+
`(allow mach-lookup ${globals([...MACH_SERVICES, ...(network ? NETWORK_MACH_SERVICES : [])])})`,
|
|
79
|
+
'(allow ipc-posix-shm-read-data ipc-posix-shm-read-metadata (ipc-posix-name "apple.shm.notification_center"))',
|
|
80
|
+
"(allow file-read-metadata)",
|
|
81
|
+
`(allow file-read* (literal "/") ${subpaths([...SYSTEM_READABLE, tmp, "/private/tmp", "/dev"])} ${subpaths(readable)})`,
|
|
82
|
+
'(allow file-ioctl (regex #"^/dev/"))',
|
|
83
|
+
...(network ? ["(allow network*)"] : []),
|
|
84
|
+
]
|
|
85
|
+
: [
|
|
86
|
+
"(allow default)",
|
|
87
|
+
...(network ? [] : ["(deny network*)"]),
|
|
88
|
+
// Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
|
|
89
|
+
// is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
|
|
90
|
+
`(deny file-read* (subpath ${q(home)}))`,
|
|
91
|
+
`(allow file-read-metadata (subpath ${q(home)}))`,
|
|
92
|
+
`(allow file-read* ${subpaths(readable)})`,
|
|
93
|
+
];
|
|
38
94
|
return [
|
|
39
95
|
"(version 1)",
|
|
40
|
-
|
|
41
|
-
...(network ? [] : ["(deny network*)"]),
|
|
42
|
-
// Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
|
|
43
|
-
// is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
|
|
44
|
-
`(deny file-read* (subpath ${q(home)}))`,
|
|
45
|
-
`(allow file-read-metadata (subpath ${q(home)}))`,
|
|
46
|
-
`(allow file-read* (subpath ${q(root)}) ${[...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs].map((p) => `(subpath ${q(p)})`).join(" ")})`,
|
|
96
|
+
...start,
|
|
47
97
|
"(deny file-write*)",
|
|
48
98
|
`(allow file-write* (subpath ${q(root)}) (subpath ${q(tmp)}) (subpath "/private/tmp") (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
|
|
49
99
|
// Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub.
|
|
50
100
|
`(deny file-write* (subpath ${q(join(root, ".agenthub"))}) ${[join(root, ".git"), ...gitDirs].map((d) => `(subpath ${q(join(d, "hooks"))}) (literal ${q(join(d, "config"))})`).join(" ")})`,
|
|
51
101
|
`(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
|
|
52
102
|
`(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
|
|
103
|
+
...(network ? [`(allow file-read* ${CA_BUNDLES.map((p) => `(literal ${q(p)})`).join(" ")})`] : []),
|
|
53
104
|
].join("\n");
|
|
54
105
|
}
|
|
55
106
|
|
package/templates/config.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"local": ["implementer", "verifier"],
|
|
15
15
|
"pi": ["implementer", "verifier"]
|
|
16
16
|
},
|
|
17
|
-
"budget": { "gate": 0.9, "stale_min": 30, "poll_min": 10, "checkpoint_timeout_s": 90, "kimi_tokens_5h": 0 },
|
|
17
|
+
"budget": { "gate": 0.9, "stale_min": 30, "poll_min": 10, "checkpoint_timeout_s": 90, "kimi_tokens_5h": 0, "wait_max_min": 30 },
|
|
18
18
|
"inference": { "enabled": true, "digest_min_items": 5, "digest_min_chars": 8000, "triage": true },
|
|
19
19
|
"omniroute": {
|
|
20
20
|
"urls": [],
|
|
@@ -23,9 +23,13 @@
|
|
|
23
23
|
"cf_client_id_file": "",
|
|
24
24
|
"cf_client_secret_file": ""
|
|
25
25
|
},
|
|
26
|
-
"local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [] },
|
|
26
|
+
"local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [], "sandbox": "deny-default" },
|
|
27
27
|
"approvals": { "timeout_s": 120 },
|
|
28
28
|
"tasks": { "release_after_min": 30 },
|
|
29
29
|
"checks": { "timeout_s": 600 },
|
|
30
|
-
"snapshots": { "enabled": true, "keep": 20 }
|
|
30
|
+
"snapshots": { "enabled": true, "keep": 20 },
|
|
31
|
+
"review": { "adaptive": false, "min_reviews": 5 },
|
|
32
|
+
"recovery": { "auto_resume_after_crash": false },
|
|
33
|
+
"capabilities": {},
|
|
34
|
+
"limits": { "sender_per_min": 12, "pair_per_min": 6, "important_per_hour": 6, "repeat_window_s": 120 }
|
|
31
35
|
}
|