@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/CHANGELOG.md +10 -0
- package/README.md +2 -2
- package/docs/events.md +2 -1
- package/docs/operations.md +108 -8
- package/docs/quickstart.md +1 -1
- package/docs/security.md +1 -0
- package/docs/smoke.md +18 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +95 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4 -4
- package/src/adapters/local-worker.ts +2 -1
- package/src/cli/main.ts +43 -8
- package/src/cli/upgrade.ts +1 -1
- package/src/hub/board.ts +24 -7
- package/src/hub/budget.ts +57 -6
- package/src/hub/bus.ts +24 -6
- package/src/hub/conflicts.ts +63 -0
- package/src/hub/daemon.ts +116 -23
- package/src/hub/events.ts +1 -0
- package/src/hub/hub-tools.ts +3 -3
- 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 +41 -4
- package/src/hub/snapshots.ts +29 -2
- package/src/hub/tasks.ts +131 -12
- package/templates/claude-hooks.json +10 -0
- package/templates/config.json +3 -2
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
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
677
|
-
//
|
|
678
|
-
|
|
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
|
|
681
|
-
|
|
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
|
|
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
|
-
|
|
690
|
-
|
|
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 (
|
|
774
|
+
if (releasing || stopping || recoveryActive()) return;
|
|
730
775
|
releasing = true;
|
|
731
776
|
try {
|
|
732
|
-
await
|
|
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 } : {}) });
|
|
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
|
|
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
|
-
|
|
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 };
|