@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.
@@ -35,12 +35,12 @@ const tool = (name: string, description: string, properties: Record<string, unkn
35
35
  });
36
36
 
37
37
  export const TASK_TOOLS: HubTool[] = [
38
- tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" } }, ["title"]),
38
+ tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" } }, ["title"]),
39
39
  tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan }, ["id"]),
40
40
  tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
41
41
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
42
- tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] } }),
43
- tool("hub_review", "Give your verdict on a task you were asked to review. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str }, ["id", "verdict"]),
42
+ tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
43
+ tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result, and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
44
44
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (your quota window is nearly used up): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str }, ["summary"]),
45
45
  tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id }, ["text"]),
46
46
  ];
@@ -49,7 +49,7 @@ export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
49
49
 
50
50
  /** Role contracts, by role name. Shown to each peer for the roles `.agenthub/config.json` gives it. */
51
51
  export const ROLE_TEXT: Record<string, string> = {
52
- planner: "planner: break work into tasks with hub_task_propose (one outcome each, the right class, paths in refs) instead of doing everything yourself.",
52
+ planner: "planner: break work into tasks with hub_task_propose (one outcome each, the right class, paths in refs, and after: [ids] for work that must wait for other tasks) instead of doing everything yourself.",
53
53
  implementer: "implementer: accept tasks assigned to you, do them, and finish with hub_task_done (summary: what changed, why, and the check you ran with its result; refs). Decline what you cannot do. Before starting work nobody assigned you, claim it with hub_task_propose naming yourself as owner, with the paths in refs. With a claim or an accept, give a plan: the files, symbols and signatures you will change and where new code goes.",
54
54
  verifier: "verifier: run the checks a task names and report what passed and what did not in hub_task_done.",
55
55
  reviewer: "reviewer: when asked to review, read the change itself, then hub_review with approved or changes_requested and a note that says what to fix.",
@@ -0,0 +1,71 @@
1
+ import type { PeerId, Priority } from "./envelope.ts";
2
+
3
+ /** Per-sender limits on what agents send (issue #38). 0 turns a limit off; the console user and the hub are never limited. */
4
+ export interface LimitsConfig {
5
+ /** Messages a sender may send per minute, to anyone. */
6
+ sender_per_min: number;
7
+ /** Messages a sender may send per minute to one recipient (a broadcast counts as one recipient, `*`). */
8
+ pair_per_min: number;
9
+ /** `important` messages a sender may send per hour: each can interrupt a running turn. */
10
+ important_per_hour: number;
11
+ /** An identical message to the same recipients within this many seconds is dropped. */
12
+ repeat_window_s: number;
13
+ }
14
+ // Off here like approvals.notify, so tests and a hub without a config file are unlimited; the template turns it on.
15
+ export const DEFAULT_LIMITS: LimitsConfig = { sender_per_min: 0, pair_per_min: 0, important_per_hour: 0, repeat_window_s: 0 };
16
+ /** What a project config gets unless it says otherwise. */
17
+ export const PROJECT_LIMITS: LimitsConfig = { sender_per_min: 12, pair_per_min: 6, important_per_hour: 6, repeat_window_s: 120 };
18
+
19
+ interface Bucket {
20
+ tokens: number;
21
+ at: number;
22
+ }
23
+
24
+ /**
25
+ * Token buckets per sender, per (sender, recipient) and for `important`, plus repeat suppression. A refusal says why
26
+ * and when to retry, and costs no token: the sender learns at once instead of a queue filling up downstream.
27
+ */
28
+ export class Limiter {
29
+ private readonly buckets = new Map<string, Bucket>();
30
+ private readonly recent = new Map<string, number>();
31
+
32
+ constructor(
33
+ private readonly cfg: LimitsConfig,
34
+ private readonly now: () => number = Date.now,
35
+ ) {}
36
+
37
+ /**
38
+ * undefined when the message may go; otherwise the reason, for the sender. `parent` is the id of what the message
39
+ * answers: "Yes." to two different questions is two messages, not a repeat.
40
+ */
41
+ admit(from: PeerId, to: PeerId[] | undefined, priority: Priority, body: string, parent = ""): string | undefined {
42
+ const now = this.now();
43
+ const audience = to?.length ? [...new Set(to)].sort() : ["*"];
44
+ const key = `${from}\0${audience.join(",")}\0${parent}\0${body.trim().replace(/\s+/g, " ")}`;
45
+ if (this.cfg.repeat_window_s > 0) {
46
+ const last = this.recent.get(key);
47
+ if (last !== undefined && now - last < this.cfg.repeat_window_s * 1000) {
48
+ return `the same message went to ${to?.length ? audience.join(", ") : "everyone"} ${Math.round((now - last) / 1000)} s ago`;
49
+ }
50
+ this.recent.set(key, now);
51
+ if (this.recent.size > 1000) for (const [k, at] of this.recent) if (now - at >= this.cfg.repeat_window_s * 1000) this.recent.delete(k);
52
+ }
53
+ const wanted: [string, number, number][] = []; // key, capacity, refill per ms
54
+ if (this.cfg.sender_per_min > 0) wanted.push([`s\0${from}`, this.cfg.sender_per_min, this.cfg.sender_per_min / 60_000]);
55
+ if (this.cfg.pair_per_min > 0) for (const r of audience) wanted.push([`p\0${from}\0${r}`, this.cfg.pair_per_min, this.cfg.pair_per_min / 60_000]);
56
+ if (priority === "important" && this.cfg.important_per_hour > 0) wanted.push([`i\0${from}`, this.cfg.important_per_hour, this.cfg.important_per_hour / 3_600_000]);
57
+ const filled = wanted.map(([key, cap, rate]) => {
58
+ const b = this.buckets.get(key) ?? { tokens: cap, at: now };
59
+ return { key, rate, b: { tokens: Math.min(cap, b.tokens + (now - b.at) * rate), at: now } };
60
+ });
61
+ const short = filled.filter((f) => f.b.tokens < 1);
62
+ if (short.length) {
63
+ const wait = Math.max(...short.map((f) => Math.ceil(Math.round((1 - f.b.tokens) / f.rate) / 1000))); // whole ms first: 1/(1/x) is not always x
64
+ if (this.cfg.repeat_window_s > 0) this.recent.delete(key); // refused, so not sent
65
+ const what = short.some((f) => f.key.startsWith("i\0")) ? "important messages" : "messages";
66
+ return `rate limited: too many ${what} from ${from}; retry after ${wait} s${what === "important messages" ? ", or send it without [IMPORTANT]" : ""}`;
67
+ }
68
+ for (const f of filled) this.buckets.set(f.key, { tokens: f.b.tokens - 1, at: now });
69
+ return undefined;
70
+ }
71
+ }
package/src/hub/peers.ts CHANGED
@@ -18,7 +18,7 @@ export interface PeerAdapter {
18
18
  start(): Promise<void>;
19
19
  stop(): Promise<void>;
20
20
  /** Set by the bus. The peer said something worth sharing. */
21
- onMessage?: (body: string, opts?: EnvelopeOpts) => void;
21
+ onMessage?: (body: string, opts?: EnvelopeOpts) => unknown;
22
22
  /** Set by the bus. */
23
23
  onState?: (state: PeerState) => void;
24
24
  /** Set by the bus. A delivery that had resolved turned out not to reach the agent: put it back. */
@@ -33,7 +33,8 @@ export const DEFAULT_WATCHDOG_MS = 300_000;
33
33
 
34
34
  /** State holder with a per-turn inactivity watchdog: a busy peer that goes silent is forced back to idle. */
35
35
  export abstract class BasePeer implements PeerAdapter {
36
- onMessage?: (body: string, opts?: EnvelopeOpts) => void;
36
+ /** Returns a reason (a string) when the hub refused the message (issue #38). */
37
+ onMessage?: (body: string, opts?: EnvelopeOpts) => unknown;
37
38
  onState?: (state: PeerState) => void;
38
39
  onFailed?: (envs: Envelope[]) => void;
39
40
  onDelivery?: (receipt: DeliveryReceipt) => void;
package/src/hub/report.ts CHANGED
@@ -6,13 +6,15 @@ export interface Report {
6
6
  peers: Record<string, { turns: number; busyMinutes: number; tokens: number }>;
7
7
  messages: { total: number; dropped: Record<string, number>; overflow: number; undeliverable: number; perTask: number };
8
8
  overlaps: { warnings: number; pairs: number };
9
+ /** Files one agent changed after another owner's open task had changed them (issue #32). */
10
+ conflicts: number;
9
11
  tasks: Record<string, number>;
10
12
  quota: { readings: number; hard: number };
11
13
  }
12
14
 
13
15
  /** The numbers `ahub report` prints, from `events.jsonl` alone (issue #40). */
14
16
  export function summarize(events: StampedEvent[]): Report {
15
- const r: Report = { peers: {}, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, tasks: {}, quota: { readings: 0, hard: 0 } };
17
+ const r: Report = { peers: {}, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
16
18
  const peer = (id: string) => (r.peers[id] ??= { turns: 0, busyMinutes: 0, tokens: 0 });
17
19
  const pairs = new Set<string>();
18
20
  const taskMessages = new Map<string, number>();
@@ -43,6 +45,9 @@ export function summarize(events: StampedEvent[]): Report {
43
45
  r.overlaps.warnings++;
44
46
  for (const o of e.others) pairs.add([e.task, o.task].sort((a, b) => a - b).join("-"));
45
47
  break;
48
+ case "conflict":
49
+ r.conflicts++;
50
+ break;
46
51
  case "quota":
47
52
  r.quota.readings++;
48
53
  if (e.hard) r.quota.hard++;
@@ -60,7 +65,7 @@ export function formatReport(r: Report): string[] {
60
65
  for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b))) lines.push(`peer ${id}: ${p.turns} turn${p.turns === 1 ? "" : "s"}, ${p.busyMinutes} busy minutes, ${p.tokens || "-"} tokens`);
61
66
  const dropped = Object.entries(r.messages.dropped).map(([k, n]) => `${n} ${k}`).join(", ");
62
67
  lines.push(`messages: ${r.messages.total} (dropped: ${dropped || "none"}; overflow ${r.messages.overflow}; undeliverable ${r.messages.undeliverable}); ${r.messages.perTask} per task that had any`);
63
- lines.push(`overlap warnings: ${r.overlaps.warnings}, task pairs: ${r.overlaps.pairs}`);
68
+ lines.push(`overlap warnings: ${r.overlaps.warnings}, task pairs: ${r.overlaps.pairs}; edit conflicts: ${r.conflicts}`);
64
69
  const tasks = Object.entries(r.tasks).sort().map(([k, n]) => `${k} ${n}`).join(", ");
65
70
  lines.push(`task events: ${tasks || "none"}`);
66
71
  lines.push(`quota readings: ${r.quota.readings} (${r.quota.hard} hard limits)`);
@@ -89,7 +89,7 @@ export function currentRouting(cwd: string, log: (line: string) => void = () =>
89
89
  }
90
90
  }
91
91
 
92
- export type Signal = "pii" | "long_context";
92
+ export type Signal = "pii" | "long_context" | "urgent";
93
93
 
94
94
  /** What the policy can see in a task. Context length is estimated from the referenced files, 3 characters per token as elsewhere. */
95
95
  export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, routing: Routing, cwd: string): Signal[] {
@@ -125,10 +125,25 @@ export function assign(
125
125
  task: Pick<Task, "class" | "signals">,
126
126
  states: Record<PeerId, PeerState>,
127
127
  routing: Routing,
128
- opts: { exclude?: PeerId[]; candidates?: PeerId[]; notReviewer?: PeerId } = {},
128
+ opts: {
129
+ exclude?: PeerId[];
130
+ candidates?: PeerId[];
131
+ notReviewer?: PeerId;
132
+ waitsFor?: number[];
133
+ /** Quota per peer from its fresh readings, and the clock they are read against (issue #36). */
134
+ quota?: Record<PeerId, { headroom: number; resetsAt?: number }>;
135
+ now?: number;
136
+ /** Peers demoted for this task's class, with their recent failure weight (issue #36). */
137
+ demoted?: Record<PeerId, number>;
138
+ /** Review record per implementer, then per reviewer, for this class (issue #35); followed only when `adaptive`. */
139
+ reviews?: Record<PeerId, Record<PeerId, { score: number; n: number }>>;
140
+ adaptive?: { min: number };
141
+ } = {},
129
142
  ): Assignment {
130
143
  const policy = routing.classes[task.class];
131
144
  const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
145
+ // Readiness is an input like peer states (issue #34): a task that waits for others goes to nobody yet.
146
+ if (opts.waitsFor?.length) return { trace: [...trace, `blocked: waits for ${opts.waitsFor.map((id) => `#${id}`).join(", ")} (not approved)`] };
132
147
  const pii = task.signals.includes("pii") && routing.constraints.pii === "local_only";
133
148
 
134
149
  const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
@@ -148,6 +163,44 @@ export function assign(
148
163
  return undefined;
149
164
  };
150
165
 
166
+ /** Quota that resets soonest gets used first: headroom per hour left in the window, at least 15 min (a task assigned that close to a reset mostly runs after it). */
167
+ const drain = (p: PeerId): number | undefined => {
168
+ const q = opts.quota?.[p];
169
+ return q?.resetsAt === undefined ? undefined : q.headroom / Math.max((q.resetsAt - (opts.now ?? 0)) / 3_600_000, 0.25);
170
+ };
171
+ /** Peers with readings swap places among themselves by drain rate; peers without (local, pi) keep theirs. */
172
+ const byDrain = (list: PeerId[]): PeerId[] => {
173
+ const slots = list.flatMap((p, i) => (drain(p) === undefined ? [] : [i]));
174
+ if (slots.length < 2) return list;
175
+ const sorted = slots.map((i) => list[i]!).sort((a, b) => drain(b)! - drain(a)!);
176
+ const out = [...list];
177
+ slots.forEach((i, k) => (out[i] = sorted[k]!));
178
+ const left = (p: PeerId) => `${p} ${Math.round(opts.quota![p]!.headroom * 100)}% left, resets in ${Math.max(0, Math.round((opts.quota![p]!.resetsAt! - (opts.now ?? 0)) / 60_000))} min`;
179
+ if (sorted.join() !== slots.map((i) => list[i]).join()) trace.push(` quota first: ${sorted.map(left).join("; ")}`);
180
+ return out;
181
+ };
182
+ /** Demoted peers go behind the rest for this class (owners only); each group is then ordered by quota. */
183
+ /** Reviewers with enough recorded reviews of this owner's work swap places by how those reviews held up. */
184
+ const byRecord = (list: PeerId[], owner: PeerId | undefined): PeerId[] => {
185
+ const record = owner ? opts.reviews?.[owner] : undefined;
186
+ if (!record || !Object.keys(record).length) return list;
187
+ trace.push(` review record with ${owner} in ${task.class}: ${Object.entries(record).map(([r, s]) => `${r} ${s.n} reviews, ${Math.round(s.score * 100)}% held`).join("; ")}${opts.adaptive ? "" : " (review.adaptive is off)"}`);
188
+ if (!opts.adaptive) return list;
189
+ const known = (p: PeerId) => (record[p] && record[p].n >= opts.adaptive!.min ? record[p].score : undefined);
190
+ const slots = list.flatMap((p, i) => (known(p) === undefined ? [] : [i]));
191
+ const sorted = slots.map((i) => list[i]!).sort((a, b) => known(b)! - known(a)!);
192
+ const out = [...list];
193
+ slots.forEach((i, k) => (out[i] = sorted[k]!));
194
+ return out;
195
+ };
196
+ const rank = (ok: PeerId[], role: "owner" | "reviewer", owner?: PeerId): PeerId[] => {
197
+ const down = role === "owner" ? ok.filter((p) => opts.demoted?.[p]) : [];
198
+ if (down.length) trace.push(` demoted for ${task.class}: ${down.map((p) => `${p} (${opts.demoted![p]!.toFixed(1)} recent failures)`).join(", ")}`);
199
+ const ranked = [...byDrain(ok.filter((p) => !down.includes(p))), ...byDrain(down)];
200
+ // The review record comes after quota, so it decides among reviewers that have one: idle, then record, then quota.
201
+ return role === "reviewer" ? byRecord(ranked, owner) : ranked;
202
+ };
203
+
151
204
  const pick = (list: PeerId[], role: "owner" | "reviewer", not?: PeerId): PeerId | undefined => {
152
205
  const ok: PeerId[] = [];
153
206
  for (const peer of list) {
@@ -155,9 +208,11 @@ export function assign(
155
208
  trace.push(` ${role} candidate ${peer}: ${why ? `skipped, ${why}` : states[peer]}`);
156
209
  if (!why) ok.push(peer);
157
210
  }
158
- const localTier = ok.filter((p) => p === LOCAL || p === PI);
211
+ const ranked = rank(ok, role, not);
212
+ // Demotion applies to local and Pi too: a demoted one loses its place ahead of the cloud peers.
213
+ const localTier = ranked.filter((p) => (p === LOCAL || p === PI) && !(role === "owner" && opts.demoted?.[p]));
159
214
  if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
160
- return ok.find((p) => states[p] === "idle") ?? ok[0];
215
+ return ranked.find((p) => states[p] === "idle") ?? ranked[0];
161
216
  };
162
217
 
163
218
  // Never the task's current owner by default: a decline or an escalation has to reach the next peer in the list.
@@ -1,9 +1,10 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { spawnSync } from "node:child_process";
3
- import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync, utimesSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { childEnv } from "./child-process.ts";
7
+ import type { Touch } from "./conflicts.ts";
7
8
 
8
9
  /**
9
10
  * Per-turn workspace snapshots (issue #33): git tree objects written through a copy of the index, so the user's index
@@ -50,7 +51,15 @@ export function snapshot(repo: Repo): string | undefined {
50
51
  const tmp = mkdtempSync(join(tmpdir(), "ahub-snap-"));
51
52
  try {
52
53
  const env = { GIT_INDEX_FILE: join(tmp, "index") };
53
- if (existsSync(join(repo.dir, "index"))) copyFileSync(join(repo.dir, "index"), env.GIT_INDEX_FILE);
54
+ const index = join(repo.dir, "index");
55
+ if (existsSync(index)) {
56
+ // git trusts an entry's stat only when the entry is older than the index file, and compares in whole seconds. A
57
+ // copy written now makes a same-size edit in the second the index was written look clean: keep the original's
58
+ // time. Taken before the copy, so an index replaced in between gives the copy an earlier time (safe), not later.
59
+ const { atime, mtime } = statSync(index);
60
+ copyFileSync(index, env.GIT_INDEX_FILE);
61
+ utimesSync(env.GIT_INDEX_FILE, atime, mtime);
62
+ }
54
63
  if (git(repo.top, ["add", "-A", "--", scope(repo), ":(exclude,glob)**/.agenthub/state/**"], env).status !== 0) return undefined;
55
64
  // The copy starts from the user's index: hub state somebody tracked or staged is still in it, and the exclude above
56
65
  // only keeps `add` from touching it. Take it out explicitly; -f because staged state the hub has rewritten since
@@ -141,6 +150,9 @@ export class Turns {
141
150
  // Opened by a starting hub: a turn still open was cut short when the last run stopped. It has no end snapshot, and
142
151
  // it may have run until now, so its window ends now (other turns that overlapped it stay unknowable).
143
152
  this.db.query("UPDATE turns SET ended = ? WHERE ended IS NULL").run(Date.now());
153
+ // ponytail: rows of finished tasks stay (a few per file per task); prune by task state if hub.db ever grows.
154
+ this.db.run("CREATE TABLE IF NOT EXISTS touches (task INTEGER NOT NULL, peer TEXT NOT NULL, path TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (task, peer, path))");
155
+ this.db.run("CREATE INDEX IF NOT EXISTS touches_path ON touches (path)"); // the PreToolUse hook looks up one path
144
156
  }
145
157
  begin(id: string, peer: string, startTree: string | undefined): void {
146
158
  this.db.query("INSERT OR REPLACE INTO turns (id, peer, started, start_tree) VALUES (?, ?, ?, ?)").run(id, peer, Date.now(), startTree ?? null);
@@ -181,6 +193,21 @@ export class Turns {
181
193
  return { paths: [...new Set(known.flatMap((r) => JSON.parse(r.changed) as string[]))], unknown: [...rows.filter((r) => !known.includes(r)).map((r) => r.id), ...pruned] };
182
194
  }
183
195
 
196
+ /** Files a peer's turn changed while it owned `task` in progress (issue #32). */
197
+ touch(task: number, peer: string, paths: string[]): void {
198
+ const at = Date.now();
199
+ const q = this.db.query("INSERT OR REPLACE INTO touches (task, peer, path, at) VALUES (?, ?, ?, ?)");
200
+ this.db.transaction(() => { for (const p of paths) q.run(task, peer, p, at); })();
201
+ }
202
+ touchesFor(tasks: number[]): Touch[] {
203
+ if (!tasks.length) return [];
204
+ return this.db.query(`SELECT * FROM touches WHERE task IN (${tasks.map(() => "?").join(",")})`).all(...tasks) as Touch[];
205
+ }
206
+ /** Other peers with a turn that was open at some point since `since`: their changes may be in this turn's diff. */
207
+ busySince(peer: string, since: number): string[] {
208
+ return (this.db.query("SELECT DISTINCT peer FROM turns WHERE peer != ? AND (ended IS NULL OR ended >= ?) ORDER BY peer").all(peer, since) as { peer: string }[]).map((r) => r.peer);
209
+ }
210
+
184
211
  /** The latest turn of a peer: a conversation can only be reverted from its latest turn. */
185
212
  latest(peer: string): TurnRecord | undefined {
186
213
  return this.list(peer, 1)[0];