@staix/agent-hub 0.8.1 → 0.9.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/src/hub/board.ts CHANGED
@@ -14,6 +14,8 @@ export interface TaskRefs {
14
14
  /** What an owner says it will change, before it starts (issue #31). Each list holds short free-text items. */
15
15
  export const PLAN_KEYS = ["paths", "symbols", "signatures", "insertion_points"] as const;
16
16
  export type TaskPlan = Partial<Record<(typeof PLAN_KEYS)[number], string[]>>;
17
+ /** Task outcomes older than this are pruned: demotion (issue #36) reads one week, seven half-lives. */
18
+ export const OUTCOMES_KEPT_MS = 7 * 24 * 3_600_000;
17
19
 
18
20
  export interface HistoryEntry {
19
21
  at: number;
@@ -32,6 +34,8 @@ export interface Task {
32
34
  refs: TaskRefs;
33
35
  /** Absent only on tasks built outside the board (tests); the board always returns one, `{}` when none was given. */
34
36
  plan?: TaskPlan;
37
+ /** Tasks that must be approved before this one is offered (issue #34); fixed when it is proposed. */
38
+ deps?: number[];
35
39
  signals: string[];
36
40
  /** consecutive changes_requested verdicts */
37
41
  rejections: number;
@@ -49,7 +53,7 @@ const MOVES: Record<TaskState, TaskState[]> = {
49
53
  approved: [],
50
54
  };
51
55
 
52
- const JSON_COLS = ["refs", "plan", "signals", "history"] as const;
56
+ const JSON_COLS = ["refs", "plan", "deps", "signals", "history"] as const;
53
57
 
54
58
  /** Task board in `.agenthub/state/hub.db`. It outlives the hub process: `ahub kill` leaves the file. */
55
59
  export class Board {
@@ -67,18 +71,22 @@ export class Board {
67
71
  id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', class TEXT NOT NULL,
68
72
  owner TEXT, reviewer TEXT, state TEXT NOT NULL, refs TEXT NOT NULL DEFAULT '{}', signals TEXT NOT NULL DEFAULT '[]',
69
73
  rejections INTEGER NOT NULL DEFAULT 0, history TEXT NOT NULL DEFAULT '[]', created INTEGER NOT NULL, updated INTEGER NOT NULL)`);
70
- // Boards from before issue #31 have no plan column.
71
- if (!(this.db.query("PRAGMA table_info(tasks)").all() as { name: string }[]).some((c) => c.name === "plan")) {
72
- this.db.run("ALTER TABLE tasks ADD COLUMN plan TEXT NOT NULL DEFAULT '{}'");
74
+ // Who did well or badly at which class, for demotion (issue #36). Only peers, classes and times: no task text.
75
+ this.db.run("CREATE TABLE IF NOT EXISTS outcomes (peer TEXT NOT NULL, class TEXT NOT NULL, ok INTEGER NOT NULL, at INTEGER NOT NULL)");
76
+ this.db.run("CREATE INDEX IF NOT EXISTS outcomes_class_at ON outcomes (class, at)");
77
+ // Boards from before issues #31 and #34 lack these columns; existing rows get the defaults.
78
+ const have = new Set((this.db.query("PRAGMA table_info(tasks)").all() as { name: string }[]).map((c) => c.name));
79
+ for (const [col, empty] of [["plan", "{}"], ["deps", "[]"]] as const) {
80
+ if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN ${col} TEXT NOT NULL DEFAULT '${empty}'`);
73
81
  }
74
82
  }
75
83
 
76
- propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; refs?: TaskRefs; plan?: TaskPlan; signals?: string[] }): Task {
84
+ propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; signals?: string[] }): Task {
77
85
  const now = Date.now();
78
86
  const history: HistoryEntry[] = [{ at: now, by, event: "proposed" }];
79
87
  const { lastInsertRowid } = this.db
80
- .query("INSERT INTO tasks (title, detail, class, state, refs, plan, signals, history, created, updated) VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?, ?)")
81
- .run(t.title, t.detail ?? "", t.class, JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now);
88
+ .query("INSERT INTO tasks (title, detail, class, state, refs, plan, deps, signals, history, created, updated) VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?, ?, ?)")
89
+ .run(t.title, t.detail ?? "", t.class, JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now);
82
90
  const task = this.get(Number(lastInsertRowid))!;
83
91
  this.changed(task, history[0]!);
84
92
  return task;
@@ -118,6 +126,15 @@ export class Board {
118
126
  return updated;
119
127
  }
120
128
 
129
+ recordOutcome(peer: PeerId, cls: TaskClass, ok: boolean, at = Date.now()): void {
130
+ this.db.query("INSERT INTO outcomes (peer, class, ok, at) VALUES (?, ?, ?, ?)").run(peer, cls, ok ? 1 : 0, at);
131
+ this.db.query("DELETE FROM outcomes WHERE at < ?").run(at - OUTCOMES_KEPT_MS); // nothing older is read
132
+ }
133
+
134
+ outcomes(cls: TaskClass, since: number): { peer: PeerId; ok: number; at: number }[] {
135
+ return this.db.query("SELECT peer, ok, at FROM outcomes WHERE class = ? AND at >= ?").all(cls, since) as { peer: PeerId; ok: number; at: number }[];
136
+ }
137
+
121
138
  close(): void {
122
139
  this.db.close();
123
140
  }
package/src/hub/budget.ts CHANGED
@@ -37,8 +37,11 @@ export interface BudgetConfig {
37
37
  poll_min: number;
38
38
  checkpoint_timeout_s: number;
39
39
  kimi_tokens_5h: number;
40
+ /** A paused peer whose window resets within this many minutes keeps its work, urgent tasks aside; 0 always hands over (issue #36). */
41
+ wait_max_min: number;
40
42
  }
41
- export const DEFAULT_BUDGET: BudgetConfig = { gate: 0.9, stale_min: 30, poll_min: 10, checkpoint_timeout_s: 90, kimi_tokens_5h: 0 };
43
+ // wait_max_min is off here, as approvals.notify is: a project config turns it on (30 unless it says otherwise).
44
+ export const DEFAULT_BUDGET: BudgetConfig = { gate: 0.9, stale_min: 30, poll_min: 10, checkpoint_timeout_s: 90, kimi_tokens_5h: 0, wait_max_min: 0 };
42
45
 
43
46
  export interface BudgetHooks {
44
47
  pause(peer: PeerId): void;
@@ -52,8 +55,8 @@ export interface BudgetHooks {
52
55
  attached(peer: PeerId): boolean;
53
56
  /** Is anybody attached who could take work over? Without one a handoff would only strip the tasks of their owner. */
54
57
  canHandOff(peer: PeerId): boolean;
55
- /** Move the peer's open work. */
56
- handoff(peer: PeerId, context: string | undefined): Promise<Moved[]>;
58
+ /** Move the peer's open work; with `urgentOnly`, only tasks marked urgent (the peer waits for its reset). */
59
+ handoff(peer: PeerId, context: string | undefined, urgentOnly: boolean): Promise<Moved[]>;
57
60
  resumed(record: PauseRecord): void;
58
61
  /** Every reading as it arrives, with the time it was measured (telemetry, issue #40). */
59
62
  reading?(peer: PeerId, windows: UsageWindow[], hard: boolean, at: number): void;
@@ -61,6 +64,10 @@ export interface BudgetHooks {
61
64
  }
62
65
 
63
66
  const HYSTERESIS = 0.1;
67
+ /** Mark a pause whose handoff was a wait, and one whose wait was undone, in its reason (issue #36). */
68
+ const WAIT_NOTE = "; keeps its work:";
69
+ const WAITED_NOTE = "; waited, then the reset moved to";
70
+ const waitedNote = (min: number, max: number) => `${WAITED_NOTE} ${min} min, beyond wait_max_min ${max}`;
64
71
  const FALLBACK_WINDOW_MINS = 300;
65
72
  /** Peers the coordinator never pauses: the local worker has no quota. */
66
73
  const EXEMPT = new Set<PeerId>(["local", "user", "hub"]);
@@ -177,7 +184,17 @@ export class Budget {
177
184
  if (open) {
178
185
  // A later reading may finally carry the reset time the first one lacked.
179
186
  const known = Math.max(0, ...over.map((r) => r.resetsAt ?? 0));
180
- if (known && known !== open.resetsAt) this.db.query("UPDATE budget_pauses SET resets_at = ? WHERE peer = ?").run(known, peer);
187
+ if (known && known !== open.resetsAt) {
188
+ // A wait was decided against the old reset (#36). If the new one is past wait_max_min (a week window crossed
189
+ // the gate meanwhile), the work has to move after all: unmarked, the next tick hands it over in full.
190
+ const waited = open.handedOff && open.reason.includes(WAIT_NOTE);
191
+ const waitMin = Math.ceil((known - this.now()) / 60_000);
192
+ if (waited && waitMin > this.cfg.wait_max_min) {
193
+ const reason = `${open.reason.slice(0, open.reason.indexOf(WAIT_NOTE))}${waitedNote(waitMin, this.cfg.wait_max_min)}`;
194
+ this.db.query("UPDATE budget_pauses SET resets_at = ?, handed_off = 0, reason = ? WHERE peer = ?").run(known, reason, peer);
195
+ this.hooks.notify(`budget: ${peer}'s reset moved to ${waitMin} min, beyond wait_max_min ${this.cfg.wait_max_min}: its work is handed over`);
196
+ } else this.db.query("UPDATE budget_pauses SET resets_at = ? WHERE peer = ?").run(known, peer);
197
+ }
181
198
  const newer = fresh.filter((r) => r.at > open.since);
182
199
  if (newer.length && fresh.every((r) => r.used < this.cfg.gate - HYSTERESIS)) this.resume(peer, "usage is back under the gate");
183
200
  return;
@@ -215,15 +232,34 @@ export class Budget {
215
232
 
216
233
  private async handOff(peer: PeerId, summary: string | undefined): Promise<void> {
217
234
  const context = summary ?? (await this.hooks.platformContext(peer).catch(() => undefined));
235
+ // Wait or hand off (issue #36): a handoff costs the next peer the whole context, so a short wait can be cheaper.
236
+ const resetsAt = this.record(peer)?.resetsAt;
237
+ const waitMin = resetsAt === undefined ? Infinity : Math.max(0, Math.ceil((resetsAt - this.now()) / 60_000));
238
+ const wait = this.cfg.wait_max_min > 0 && waitMin <= this.cfg.wait_max_min;
218
239
  let moved: Moved[];
219
240
  try {
220
- moved = await this.hooks.handoff(peer, context);
241
+ moved = await this.hooks.handoff(peer, context, wait);
221
242
  } catch (e) {
222
243
  // Left unmarked on purpose: the next tick, or the next hub run, tries again. The peer stays paused either way.
223
244
  return this.hooks.notify(`budget: handing over ${peer}'s work failed, will retry: ${(e as Error).message}`);
224
245
  }
225
246
  if (this.closed) return;
226
- this.db.query("UPDATE budget_pauses SET handed_off = 1, moved = ? WHERE peer = ?").run(JSON.stringify(moved), peer);
247
+ const record = this.record(peer);
248
+ // A wait that was undone hands over a second time: keep what moved the first time (urgent work) on the record too.
249
+ const all = JSON.stringify([...(record?.moved ?? []), ...moved]);
250
+ const nowMin = record ? Math.ceil((record.resetsAt - this.now()) / 60_000) : waitMin;
251
+ if (wait && nowMin > this.cfg.wait_max_min) {
252
+ // A reading moved the reset past the limit while the urgent work moved: no wait after all. Left unmarked, so the
253
+ // next tick hands over the rest.
254
+ this.db.query("UPDATE budget_pauses SET moved = ?, reason = reason || ? WHERE peer = ?").run(all, waitedNote(nowMin, this.cfg.wait_max_min), peer);
255
+ this.hooks.notify(`budget: ${peer}'s reset moved to ${nowMin} min, beyond wait_max_min ${this.cfg.wait_max_min}: its work is handed over`);
256
+ } else {
257
+ let why = "";
258
+ if (wait) why = `${WAIT_NOTE} resets in ${waitMin} min, within wait_max_min ${this.cfg.wait_max_min}`;
259
+ else if (this.cfg.wait_max_min > 0 && waitMin !== Infinity && !record?.reason.includes(WAITED_NOTE)) why = `; handed over: resets in ${waitMin} min, beyond wait_max_min ${this.cfg.wait_max_min}`;
260
+ this.db.query("UPDATE budget_pauses SET handed_off = 1, moved = ?, reason = reason || ? WHERE peer = ?").run(all, why, peer);
261
+ if (wait) this.hooks.notify(`budget: ${peer} keeps its work and waits ${waitMin} min for its reset (wait_max_min ${this.cfg.wait_max_min})${moved.length ? "; urgent work moves" : ""}`);
262
+ }
227
263
  if (moved.length) this.hooks.notify(`budget: moved from ${peer}: ${moved.map((m) => `#${m.id} ${m.role} -> ${m.to ?? "nobody"}`).join(", ")}`);
228
264
  }
229
265
 
@@ -256,6 +292,21 @@ export class Budget {
256
292
  this.hooks.notify(`budget: ${peer} resumed (${why})`);
257
293
  }
258
294
 
295
+ /**
296
+ * For routing (issue #36): per peer, the headroom of its most used fresh window and that window's reset. Headroom a
297
+ * week window bounds is not lost when the 5 h window resets, so the reset is the binding window's, the latest on a tie.
298
+ */
299
+ headroom(): Record<PeerId, { headroom: number; resetsAt?: number }> {
300
+ return Object.fromEntries([...this.readings.keys()].flatMap((peer) => {
301
+ const fresh = this.fresh(peer);
302
+ if (!fresh.length) return [];
303
+ const used = Math.max(...fresh.map((r) => r.used));
304
+ const binding = fresh.filter((r) => r.used === used);
305
+ const resetsAt = binding.some((r) => r.resetsAt === undefined) ? undefined : Math.max(...binding.map((r) => r.resetsAt!));
306
+ return [[peer, { headroom: 1 - used, ...(resetsAt !== undefined ? { resetsAt } : {}) }]];
307
+ }));
308
+ }
309
+
259
310
  status(): Record<PeerId, { windows: (Reading & { stale: boolean })[]; paused?: { reason: string; resetsAt: number; since: number } }> {
260
311
  const staleMs = this.cfg.stale_min * 60_000;
261
312
  const peers = new Set([...this.readings.keys(), ...this.records().map((r) => r.peer)]);
package/src/hub/bus.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, newEnvelope, parseMarker, quoteNotes, replyAudience, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
1
+ import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
2
2
  import type { PeerAdapter } from "./peers.ts";
3
3
  import { DeliveryJournal, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
4
4
 
@@ -24,6 +24,11 @@ export interface BusOptions {
24
24
  /** Optional: rewrite a delivery before it goes out (M6 digest condensation). Must return its input on any failure. */
25
25
  condense?: (envs: Envelope[]) => Promise<Envelope[]>;
26
26
  journal?: DeliveryJournal;
27
+ /**
28
+ * Optional: may an agent send this envelope now (issue #38)? `parent` is the id of what it answers. A reason refuses
29
+ * it, and the sender hears it on its next delivery.
30
+ */
31
+ admit?: (env: Envelope, parent?: string) => string | undefined;
27
32
  }
28
33
 
29
34
  /** Serializable delivery state used by the controlled restart coordinator. Bodies stay in the private daemon file. */
@@ -133,11 +138,24 @@ export class Bus {
133
138
  // `digest` in the audience stands for the condensed delivery even when a steer, not the digest, is the parent.
134
139
  const condensed = !!last?.out.some((e) => e.from === DIGEST);
135
140
  const audienceOriginals = originals ?? (opts?.to?.includes(DIGEST) && condensed ? last?.originals : undefined);
136
- this.publish(newEnvelope(peer.id, body, {
137
- ...opts,
138
- ...(opts?.to?.length ? { to: resolveTo(opts.to, audienceOriginals) } : {}),
139
- priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals),
140
- }));
141
+ const to = opts?.to?.length ? resolveTo(opts.to, audienceOriginals) : undefined;
142
+ let env = newEnvelope(peer.id, body, { ...opts, ...(to ? { to } : {}), priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals) });
143
+ // Limits count what is sent: the envelope as built (a reply goes to its parent's sender, `digest` is resolved,
144
+ // the priority is capped), never the raw `to`.
145
+ const parent = opts?.inReplyTo?.id;
146
+ let refused = this.opts.admit?.(env, parent);
147
+ // A turn answer has no caller to refuse: over its important budget it goes out as status, not at all.
148
+ if (refused && env.priority === "important" && !this.opts.admit?.({ ...env, priority: "status" }, parent)) {
149
+ // It went out, so the advice on how to send it does not apply.
150
+ this.note(peer.id, noteLine(HUB, "decision", `your [IMPORTANT] message went out as status: ${refused.replace(/[;:] (retry after \d+ s, or )?send it without \[IMPORTANT\]$/, "")}`));
151
+ env = { ...env, priority: "status" };
152
+ refused = undefined;
153
+ }
154
+ if (refused) {
155
+ this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered: ${refused}`));
156
+ return refused;
157
+ }
158
+ this.publish(env);
141
159
  };
142
160
  peer.onFailed = (envs) => {
143
161
  // The adapter got the condensed list; what has to come back is what that list replaced.
@@ -0,0 +1,63 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { existsSync } from "node:fs";
3
+ import type { Task } from "./board.ts";
4
+ import { samePlace } from "./tasks.ts";
5
+
6
+ /** A file an owner's open task changed, as the turn snapshots saw it (issue #32). */
7
+ export interface Touch {
8
+ task: number;
9
+ peer: string;
10
+ path: string;
11
+ at: number;
12
+ }
13
+
14
+ /**
15
+ * The files `peer` just changed that another owner's open task changed before it, grouped by that task. Pure: the
16
+ * daemon passes the turn's files, the recorded touches and the open tasks, PII ones already left out. A file only one
17
+ * agent touched is never a conflict, even when the task it was touched for has changed hands since.
18
+ */
19
+ export function conflictsOf(peer: string, changed: string[], touches: Touch[], open: Task[]): { task: Task; paths: string[] }[] {
20
+ const tasks = new Map(open.map((t) => [t.id, t]));
21
+ const mine = new Set(changed);
22
+ const hits = new Map<number, Set<string>>();
23
+ for (const t of touches) {
24
+ const task = tasks.get(t.task);
25
+ if (!task || task.owner === peer || t.peer === peer || !mine.has(t.path)) continue;
26
+ if (!hits.has(t.task)) hits.set(t.task, new Set());
27
+ hits.get(t.task)!.add(t.path);
28
+ }
29
+ return [...hits].map(([id, paths]) => ({ task: tasks.get(id)!, paths: [...paths].sort() }));
30
+ }
31
+
32
+ /**
33
+ * For `ahub check-path`, the Claude Code PreToolUse hook (issue #32): other owners' open tasks that claim a file (refs or
34
+ * plan paths) or changed it in a turn. Reads hub.db only, so it works whether or not the hub runs. PII tasks are left
35
+ * out: the hook's text reaches a cloud model. `project` is the file relative to the project root, `repo` relative to
36
+ * the repository's top level (where turn snapshots record paths).
37
+ */
38
+ export function pathWarnings(dbFile: string, peer: string, file: { project: string; repo?: string }): string[] {
39
+ if (!existsSync(dbFile)) return [];
40
+ const db = new Database(dbFile, { readonly: true });
41
+ try {
42
+ const rows = db.query("SELECT * FROM tasks WHERE owner IS NOT NULL AND owner != ? AND state IN ('proposed', 'in_progress', 'changes_requested') ORDER BY id").all(peer) as Record<string, string | number | null>[];
43
+ let touches: Touch[] = [];
44
+ try {
45
+ if (file.repo) touches = db.query("SELECT * FROM touches WHERE path = ? AND peer != ?").all(file.repo, peer) as Touch[];
46
+ } catch {
47
+ // a hub without snapshots never made the table
48
+ }
49
+ return rows.flatMap((r) => {
50
+ if ((JSON.parse(String(r.signals ?? "[]")) as string[]).includes("pii")) return [];
51
+ const refs = JSON.parse(String(r.refs ?? "{}")) as { paths?: string[] };
52
+ const plan = JSON.parse(String(r.plan ?? "{}")) as { paths?: string[] };
53
+ const claims = [...(refs.paths ?? []), ...(plan.paths ?? [])].some((p) => samePlace(p, file.project));
54
+ const changed = touches.some((t) => t.task === r.id);
55
+ if (!claims && !changed) return [];
56
+ const how = [changed ? "changed it" : "", claims ? "claims it" : ""].filter(Boolean).join(" and ");
57
+ // The title is another agent's text: quoted as a JSON string, so a line break in it cannot start a line of its own.
58
+ return [`task #${r.id} ${JSON.stringify(String(r.title).slice(0, 100))} (owner ${r.owner}, ${r.state}) ${how}`];
59
+ });
60
+ } finally {
61
+ db.close();
62
+ }
63
+ }
package/src/hub/daemon.ts CHANGED
@@ -36,11 +36,13 @@ import { Bus } from "./bus.ts";
36
36
  import { DeliveryJournal } from "./delivery-journal.ts";
37
37
  import { startDashboard } from "./ui.ts";
38
38
  import { PROTOCOL, stateDirFor } from "./control-client.ts";
39
- import { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId } from "./envelope.ts";
39
+ import { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
40
40
  import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
41
41
  import { MemoryClient, workerUrl } from "../memory/client.ts";
42
42
  import { VERSION } from "../version.ts";
43
43
  import { projectChain, recallFor } from "../memory/recall.ts";
44
+ import { conflictsOf } from "./conflicts.ts";
45
+ import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
44
46
  import { changedPaths, repoOf, snapshot, Turns } from "./snapshots.ts";
45
47
  import { archiveRestartSnapshot, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
46
48
 
@@ -67,6 +69,8 @@ export interface HubConfig {
67
69
  checks: { timeout_s: number; [cls: string]: string | number };
68
70
  /** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
69
71
  snapshots: { enabled: boolean; keep: number };
72
+ /** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
73
+ limits: LimitsConfig;
70
74
  /** Machine-local fields a config file set but git could not vouch for, and why (issue #17). */
71
75
  ignored?: string[];
72
76
  }
@@ -91,6 +95,7 @@ export const DEFAULT_CONFIG: HubConfig = {
91
95
  checks: { timeout_s: 600 },
92
96
  // Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
93
97
  snapshots: { enabled: false, keep: 20 },
98
+ limits: DEFAULT_LIMITS,
94
99
  };
95
100
 
96
101
  export { stateDirFor };
@@ -101,7 +106,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
101
106
 
102
107
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
103
108
  const CONFIG_FILES = ["config.json", "config.local.json"] as const;
104
- const CONFIG_BLOCKS = ["memory", "roles", "budget", "inference", "omniroute", "local", "pi", "approvals", "tasks", "checks", "snapshots", "mlx"];
109
+ const CONFIG_BLOCKS = ["memory", "roles", "budget", "inference", "omniroute", "local", "pi", "approvals", "tasks", "checks", "snapshots", "limits", "mlx"];
105
110
 
106
111
  export function loadConfig(cwd: string): HubConfig {
107
112
  const ignored: string[] = [];
@@ -133,7 +138,7 @@ export function loadConfig(cwd: string): HubConfig {
133
138
  ...file,
134
139
  memory: { ...DEFAULT_CONFIG.memory, ...file.memory },
135
140
  roles: { ...DEFAULT_CONFIG.roles, ...file.roles },
136
- budget: { ...DEFAULT_CONFIG.budget, ...file.budget },
141
+ budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
137
142
  inference: { ...DEFAULT_CONFIG.inference, ...file.inference },
138
143
  omniroute: { ...DEFAULT_CONFIG.omniroute, ...file.omniroute },
139
144
  local: { ...DEFAULT_CONFIG.local, ...file.local },
@@ -145,6 +150,7 @@ export function loadConfig(cwd: string): HubConfig {
145
150
  tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks },
146
151
  checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
147
152
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
153
+ limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
148
154
  mlx,
149
155
  ...(ignored.length ? { ignored } : {}),
150
156
  };
@@ -277,7 +283,24 @@ export async function startDaemon(opts: DaemonOptions) {
277
283
  // The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
278
284
  let inference: Inference | undefined;
279
285
  const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation });
280
- const bus = new Bus({ journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs) });
286
+ // Agents only: the console user and the hub itself are never limited (issue #38).
287
+ // A typo such as "12/min" would read as 0, which turns a limit off without a word: the project default instead.
288
+ for (const k of Object.keys(config.limits)) if (!(k in PROJECT_LIMITS)) log(`limits.${k} is not a known limit; ignored`);
289
+ const limits = Object.fromEntries(Object.entries(PROJECT_LIMITS).map(([k, fallback]) => {
290
+ const v = config.limits[k as keyof LimitsConfig];
291
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0) return [k, v];
292
+ log(`limits.${k}: ${JSON.stringify(v)} is not a number of 0 or more; using ${fallback}`);
293
+ return [k, fallback];
294
+ })) as unknown as LimitsConfig;
295
+ const limiter = new Limiter(limits);
296
+ const admit = (env: Envelope, parent?: string): string | undefined => {
297
+ // [FYI] is recorded and costs nobody a turn: nothing to limit.
298
+ if (env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi") return undefined;
299
+ const refused = limiter.admit(env.from, env.to, env.priority, env.body, parent);
300
+ if (refused) log(`limits: ${env.from}: ${refused}`);
301
+ return refused;
302
+ };
303
+ const bus = new Bus({ journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit });
281
304
  startupCleanup.push(() => bus.closeJournal());
282
305
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
283
306
  let recoveryOperationId: string | undefined;
@@ -386,6 +409,7 @@ export async function startDaemon(opts: DaemonOptions) {
386
409
  }
387
410
  },
388
411
  triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
412
+ quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
389
413
  });
390
414
  board.onChange = (t, h) => event({ type: "task", id: t.id, event: h.event, by: h.by, state: t.state, owner: t.owner, reviewer: t.reviewer, class: t.class, pii: tasks.isPii(t) });
391
415
  // ---- budget relay -------------------------------------------------------------------------------------------
@@ -423,9 +447,9 @@ export async function startDaemon(opts: DaemonOptions) {
423
447
  attached: (peer) => bus.peers.has(peer),
424
448
  // Somebody other than the paused peer has to be there, or the handoff would only leave its tasks without an owner.
425
449
  canHandOff: (peer) => [...bus.peers.keys()].some((id) => id !== peer && ["idle", "busy"].includes(bus.stateOf(id))),
426
- handoff: (peer, context) => tasks.reassignForPause(peer, context),
427
450
  // A reading that arrived through a file carries the file's time; a stale one must not look fresh in the export.
428
451
  reading: (peer, windows, hard, at) => event({ type: "quota", peer, windows: windows.map((w) => ({ id: w.id, used: w.used, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) })), hard, ...(Math.abs(Date.now() - at) > 1000 ? { measuredAt: new Date(at).toISOString() } : {}) }),
452
+ handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
429
453
  resumed: (record) => {
430
454
  if (record.peer === "kimi") kimiTokens.length = 0; // a new window: the old counts would pause it again at once
431
455
  const moved = record.moved.length ? `While you were paused these moved: ${record.moved.map((m) => `${m.title} (${m.role} -> ${m.to ?? "nobody"})`).join("; ")}. They stay where they are; ask the user if you should take one back.` : "Nothing was moved while you were paused.";
@@ -519,7 +543,19 @@ export async function startDaemon(opts: DaemonOptions) {
519
543
  const toolEnv = (peer: PeerId) => ({ AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
520
544
 
521
545
  /** One entry point for the task tools, whoever calls them: MCP clients, the local worker, the console. */
522
- async function taskOp(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
546
+ // A task op can write the board across awaits (triage, briefs, the dependents an approval releases): a recovery
547
+ // commit waits for those in flight, or its integrity digest misses their later writes. Completion checks outlive
548
+ // their op, so recoveryReady() also waits for `tasks.checksPending()`: a check the commit's stop kills would write.
549
+ let taskOpsInFlight = 0;
550
+ const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
551
+ taskOpsInFlight++;
552
+ try {
553
+ return await taskOpBody(...args);
554
+ } finally {
555
+ taskOpsInFlight--;
556
+ }
557
+ };
558
+ async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
523
559
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
524
560
  // claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
525
561
  if (piiTurn && (op === "hub_remember" || op === "hub_task_propose")) throw new Error(`${op} is not available while working on a PII task: its text must not leave this machine`);
@@ -563,7 +599,7 @@ export async function startDaemon(opts: DaemonOptions) {
563
599
  return "checkpoint received; you will be paused now and resumed when your window resets";
564
600
  }
565
601
  case "hub_task_list":
566
- return JSON.stringify(board.list(a.state).map((t) => (onPrem ? t : tasks.publicView(t))).map(({ history: _h, ...t }) => t));
602
+ return JSON.stringify(board.list(a.ready === true ? "proposed" : a.state).filter((t) => a.ready !== true || !tasks.waitsFor(t).length).map((t) => (onPrem ? t : tasks.publicView(t))).map(({ history: _h, ...t }) => t));
567
603
  }
568
604
  if (by !== USER) throw new Error(`${op} is a console command`);
569
605
  switch (op) {
@@ -614,9 +650,9 @@ export async function startDaemon(opts: DaemonOptions) {
614
650
  const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
615
651
  return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : manualPaused.has(id) && bus.stateOf(id) === "offline" ? { paused: "manual" } : {};
616
652
  };
617
- let releasing = false; // gone-owner release (#6): one run at a time, and a recovery commit waits for it
653
+ let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
618
654
  const recoveryReady = () => {
619
- if (!recoveryActive() || releasing || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
655
+ if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
620
656
  if (!recoveryPeerSnapshot) return true;
621
657
  const current = recoveryPeers();
622
658
  return recoveryPeerSnapshot.every((saved) => {
@@ -673,21 +709,30 @@ export async function startDaemon(opts: DaemonOptions) {
673
709
  return [id, row];
674
710
  }));
675
711
  const digest = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
676
- // `prePlan`: the board as a hub from before the plan column (#31, 0.8.0) digested it. An empty plan is left out, so an
677
- // upgrade from such a hub still verifies the board it was handed; a real plan never matches that shape.
678
- const integrity = (prePlan = false) => {
712
+ // Task columns added since a source may have recorded its digest, newest first, with their empty values. A hub from
713
+ // before them digested its rows without them (0.8.x: no deps, #34; 0.7.x: no plan either, #31). `older` leaves out
714
+ // the newest `older` of them while they are empty, so an upgrade from such a hub verifies the board it was handed; a
715
+ // filled one stays in the row and never matches.
716
+ const ADDED_COLUMNS = [["deps", "[]"], ["plan", "{}"]] as const;
717
+ const integrity = (older = 0) => {
679
718
  const queues = Object.fromEntries(Object.keys(bus.snapshot().queues).sort().map((id) => [id, bus.queueIds(id)]));
680
- const tasks = board.list().sort((a, b) => a.id - b.id);
681
- const boardState = prePlan ? tasks.map(({ plan, ...t }) => (plan && Object.keys(plan).length ? { ...t, plan } : t)) : tasks;
719
+ const boardState = board.list().sort((a, b) => a.id - b.id).map((t) => {
720
+ const row: Record<string, unknown> = { ...t };
721
+ for (const [col, empty] of ADDED_COLUMNS.slice(0, older)) if (JSON.stringify(row[col]) === empty) delete row[col];
722
+ return row;
723
+ });
682
724
  const budgetState = budget.persistedPauseDigestRows().sort((a, b) => a.peer.localeCompare(b.peer));
683
725
  return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState) };
684
726
  };
685
- /** The integrity in the shape `expected` was recorded in: the current one unless only the pre-plan shape matches. */
727
+ /** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
686
728
  const integrityAs = (expected: unknown) => {
687
729
  const now = integrity();
688
730
  if (!expected || JSON.stringify(expected) === JSON.stringify(now)) return now;
689
- const old = integrity(true);
690
- return JSON.stringify(expected) === JSON.stringify(old) ? old : now;
731
+ for (let older = 1; older <= ADDED_COLUMNS.length; older++) {
732
+ const then = integrity(older);
733
+ if (JSON.stringify(expected) === JSON.stringify(then)) return then;
734
+ }
735
+ return now;
691
736
  };
692
737
  const status = () => ({
693
738
  projectId,
@@ -726,10 +771,11 @@ export async function startDaemon(opts: DaemonOptions) {
726
771
  const hubStartedAt = Date.now();
727
772
  const releaseGoneOwners = async () => {
728
773
  const limit = config.tasks.release_after_min;
729
- if (!(limit > 0) || releasing || stopping || recoveryActive()) return;
774
+ if (releasing || stopping || recoveryActive()) return;
730
775
  releasing = true;
731
776
  try {
732
- await releaseOwners(limit);
777
+ await tasks.releaseReady(); // #34: dependents a stop cut off between an approval and their assignment
778
+ if (limit > 0) await releaseOwners(limit);
733
779
  } finally {
734
780
  releasing = false;
735
781
  }
@@ -749,6 +795,44 @@ export async function startDaemon(opts: DaemonOptions) {
749
795
  releaseTimer.unref?.();
750
796
  intervals.push(releaseTimer);
751
797
 
798
+ // Early conflict detection (issue #32): the files a turn changed, against what other owners' open tasks changed
799
+ // before it. Warns both owners once per file and task; never blocks a write.
800
+ const conflictSeen = new Set<string>();
801
+ const detectConflicts = (peer: PeerId, turnId: string, since: number, changed: string[]) => {
802
+ const open = board.list().filter((t) => t.owner && ["proposed", "in_progress", "changes_requested"].includes(t.state));
803
+ const mine = open.filter((t) => t.owner === peer && t.state === "in_progress");
804
+ if (mine.some((t) => tasks.isPii(t))) return; // a PII turn's files are nobody else's business
805
+ const visible = open.filter((t) => !tasks.isPii(t));
806
+ const found = conflictsOf(peer, changed, turnLog!.touchesFor(visible.map((t) => t.id)), visible);
807
+ // A turn's diff holds whatever changed while it ran. Only what no overlapping turn of another peer changed is
808
+ // recorded as this peer's; while such a turn's changes are unknown (still running, say), nothing is (review of #49).
809
+ const record = turnLog!.get(turnId);
810
+ const overlap = record ? turnLog!.overlapping(record, Math.max(1, Number(config.snapshots.keep) || 20)) : { paths: [], unknown: [] };
811
+ if (!overlap.unknown.length) {
812
+ const theirs = new Set(overlap.paths);
813
+ const own = changed.filter((p) => !theirs.has(p));
814
+ for (const t of mine) turnLog!.touch(t.id, peer, own);
815
+ }
816
+ if (!found.length) return;
817
+ const others = turnLog!.busySince(peer, since);
818
+ const concurrent = others.length ? ` Concurrent: ${others.join(", ")} also worked during that turn, so some of these changes may be theirs.` : "";
819
+ const ours = mine.length ? ` (task ${mine.map((t) => `#${t.id}`).join(", ")})` : "";
820
+ for (const { task, paths: all } of found) {
821
+ const paths = all.filter((p) => !conflictSeen.has(`${peer}\0${task.id}\0${p}`));
822
+ if (!paths.length) continue;
823
+ for (const p of paths) conflictSeen.add(`${peer}\0${task.id}\0${p}`);
824
+ const owner = task.owner!;
825
+ // As with overlaps (#31): a file name that matches a PII pattern is never named to peers, the log or telemetry.
826
+ const named = paths.filter(tasks.nameable);
827
+ const hidden = paths.length - named.length;
828
+ const files = [...named, ...(hidden ? [`${hidden} file(s) whose names are withheld (they match a PII pattern)`] : [])].join(", ");
829
+ notify(`conflict: ${peer}${ours} changed ${files}, which #${task.id} (owner ${owner}) changed before${others.length ? ` (concurrent: ${others.join(", ")})` : ""}`);
830
+ event({ type: "conflict", peer, ...(mine[0] ? { task: mine[0].id } : {}), other: task.id, owner, paths: named, concurrent: others.length > 0 });
831
+ bus.publish(newEnvelope(HUB, `Your last turn${ours} changed ${files}, which ${owner}'s open task (${tasks.publicTitle(task)}) changed before it. Check that you did not overwrite that work, and settle it with ${owner} via hub_send.${concurrent}`, { to: [peer], kind: "task", ...(mine[0] ? { refs: { task: String(mine[0].id) } } : {}) }));
832
+ if (owner !== USER && owner !== HUB) bus.publish(newEnvelope(HUB, `${peer}'s last turn${ours} changed ${files}, which your open task #${task.id} changed before it. Check that your work there is intact.${concurrent}`, { to: [owner], kind: "task", refs: { task: String(task.id) } }));
833
+ }
834
+ };
835
+
752
836
  bus.tap((e) => {
753
837
  e = redact(e);
754
838
  uiEvents.push({ seq: ++uiSequence, event: e });
@@ -770,6 +854,7 @@ export async function startDaemon(opts: DaemonOptions) {
770
854
  event({ type: "turn_start", peer: e.peer, turn: id });
771
855
  } else if (!busy && open) {
772
856
  turns.delete(e.peer);
857
+ let afterTurn: (() => void) | undefined;
773
858
  let files: number | undefined;
774
859
  let snapshotMs = open.snapshotMs;
775
860
  if (turnLog && (open.private || holdsPii(e.peer))) {
@@ -780,8 +865,11 @@ export async function startDaemon(opts: DaemonOptions) {
780
865
  if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
781
866
  snapshotMs = (snapshotMs ?? 0) + end.ms;
782
867
  try { turnLog.end(open.id, end.tree, changed, Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
868
+ // After the turn_end event: a notice delivered at once starts the peer's next turn, which must come after it.
869
+ if (changed.length) afterTurn = () => detectConflicts(e.peer, open.id, open.start, changed);
783
870
  }
784
871
  event({ type: "turn_end", peer: e.peer, turn: open.id, ms: Date.now() - open.start, ...(open.tokens ? { tokens: open.tokens } : {}), ...(files !== undefined ? { files, snapshotMs } : {}) });
872
+ try { afterTurn?.(); } catch (error) { log(`conflict check after ${open.id}: ${(error as Error).message}`); }
785
873
  }
786
874
  if (e.state === "offline") offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now());
787
875
  else offlineSince.delete(e.peer);
@@ -998,7 +1086,8 @@ export async function startDaemon(opts: DaemonOptions) {
998
1086
  permit: (title) => onPermission({ peer: "pi", title, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }).then((picked) => picked === "allow" && pi.acceptingTools && bus.peers.get("pi") === pi),
999
1087
  send: (text, to) => {
1000
1088
  if (to?.some((id) => !bus.peers.has(id) && id !== USER)) return "error: unknown peer";
1001
- pi.onMessage?.(text, { inReplyTo: piReply, ...(to?.length ? { to } : {}) }); return "sent";
1089
+ const refused = pi.onMessage?.(text, { inReplyTo: piReply, ...(to?.length ? { to } : {}) });
1090
+ return typeof refused === "string" ? `not sent: ${refused}` : "sent";
1002
1091
  },
1003
1092
  };
1004
1093
  const routing = currentRouting(opts.cwd, log);
@@ -1270,7 +1359,7 @@ export async function startDaemon(opts: DaemonOptions) {
1270
1359
  return { t: "recovery", ok: true, aborted: true, recovery: recoveryView() };
1271
1360
  }
1272
1361
  if (msg.op === "commit") {
1273
- if ((recoveryPhase !== "prepared" && recoveryPhase !== "preparing") || !recoveryReady()) return recoveryError("recovery is not ready; inspect until peers are idle and approvals are complete");
1362
+ if ((recoveryPhase !== "prepared" && recoveryPhase !== "preparing") || !recoveryReady()) return recoveryError("recovery is not ready; inspect until peers are idle, approvals are complete and completion checks have finished");
1274
1363
  recoveryPhase = "prepared";
1275
1364
  recoveryPeerSnapshot ??= Object.values(recoveryPeers());
1276
1365
  const currentPeers = recoveryPeers();
@@ -1395,11 +1484,15 @@ export async function startDaemon(opts: DaemonOptions) {
1395
1484
  // A human at the console should not wait out the batch window; agents default to status.
1396
1485
  const { priority, body } = parseMarker(String(msg.body ?? ""), c.peer ? "status" : "important");
1397
1486
  if (!body) return void reply({ t: "sent", ok: false, error: "empty body" });
1398
- const to: PeerId[] | undefined = Array.isArray(msg.to) && msg.to.length ? msg.to.map(String) : undefined;
1487
+ const to: PeerId[] | undefined = Array.isArray(msg.to) && msg.to.length ? [...new Set<string>(msg.to.map(String))] : undefined;
1399
1488
  const unknown = to?.filter((id) => !bus.knownPeers().includes(id)) ?? [];
1400
1489
  if (unknown.length) return void reply({ t: "sent", ok: false, error: `unknown peer: ${unknown.join(", ")}` });
1401
1490
  const inReplyTo = msg.reply_to ? bus.get(String(msg.reply_to)) : undefined;
1402
- const targets = bus.publish(newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) }));
1491
+ // Built first: limits count the audience it really has (a reply goes to the parent's sender).
1492
+ const env = newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) });
1493
+ const refused = c.peer ? admit(env, inReplyTo?.id) : undefined;
1494
+ if (refused) return void reply({ t: "sent", ok: false, error: refused });
1495
+ const targets = bus.publish(env);
1403
1496
  if (c.peer && inReplyTo) bus.completeReply(c.peer, inReplyTo.id);
1404
1497
  return void reply({ t: "sent", ok: true, targets, recorded: priority === "fyi" });
1405
1498
  }
package/src/hub/events.ts CHANGED
@@ -16,6 +16,7 @@ export type HubEvent =
16
16
  | { type: "tokens"; peer: string; n: number }
17
17
  | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
18
18
  | { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
19
+ | { type: "conflict"; peer: string; task?: number; other: number; owner: string; paths: string[]; concurrent: boolean }
19
20
  | { type: "quota"; peer: string; windows: { id: string; used: number; resetsAt?: number }[]; hard: boolean; measuredAt?: string };
20
21
 
21
22
  export type StampedEvent = HubEvent & { v: number; at: string };