agent-coord-mcp 0.26.6 → 0.26.8

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.
@@ -0,0 +1,199 @@
1
+ /*
2
+ * Event subscriptions — stop relying on someone CHOOSING to tell you.
3
+ *
4
+ * The bus is almost entirely direct messaging: an agent learns something
5
+ * happened because another agent decided to say so. Every miss this week was a
6
+ * missing NOTIFICATION rather than a missing capability — two mergeable PRs sat
7
+ * 17 hours because nobody told the coordinator to gate, the console's trigger
8
+ * ran zero times because nothing woke it, and `stall_check` runs only when a
9
+ * human types it.
10
+ */
11
+ import { createHash, randomUUID } from "node:crypto";
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
13
+ import path from "node:path";
14
+ import { z } from "zod";
15
+ import { ROOT } from "../store.js";
16
+
17
+ const subsFile = () => path.join(ROOT, "subscriptions.json");
18
+
19
+ export type SubKind = "task" | "phase" | "item" | "pr";
20
+ export type Subscription = {
21
+ id: string;
22
+ agentId: string;
23
+ kind: SubKind;
24
+ target: string;
25
+ createdAt: number;
26
+ /** null until this subscription has been EVALUATED at least once. */
27
+ lastEvaluatedAt: number | null;
28
+ lastEventAt: number | null;
29
+ /** Idempotency keys already delivered, for 6.4. */
30
+ delivered: string[];
31
+ };
32
+
33
+ export function readSubs(): Subscription[] {
34
+ const f = subsFile();
35
+ if (!existsSync(f)) return [];
36
+ try {
37
+ return (JSON.parse(readFileSync(f, "utf8")).subscriptions ?? []) as Subscription[];
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
43
+ function writeSubs(subs: Subscription[]): void {
44
+ mkdirSync(ROOT, { recursive: true });
45
+ writeFileSync(subsFile(), `${JSON.stringify({ subscriptions: subs }, null, 2)}\n`);
46
+ }
47
+
48
+ /**
49
+ * 6.3 — A SUBSCRIPTION THAT NEVER FIRES MUST BE DISTINGUISHABLE FROM ONE NEVER
50
+ * REGISTERED, so never-evaluated is an ERROR rather than a quiet zero.
51
+ *
52
+ * We hit the absence of this rule twice in two days: the console's standing
53
+ * trigger and `stall_check`'s clock both looked healthy while never running. A
54
+ * subscription with no last-evaluated mark has produced no evidence of
55
+ * anything, and "no events" is the same output a broken subscription gives.
56
+ */
57
+ export function subscriptionHealth(s: Subscription): { level: "ok" | "error"; detail: string } {
58
+ if (s.lastEvaluatedAt === null)
59
+ return {
60
+ level: "error",
61
+ detail: `never evaluated — this subscription has produced no evidence it is wired to anything. "No events yet" and "never ran" are the same output, and only one of them is healthy.`,
62
+ };
63
+ return {
64
+ level: "ok",
65
+ detail: s.lastEventAt
66
+ ? `last evaluated ${new Date(s.lastEvaluatedAt).toISOString()}, last event ${new Date(s.lastEventAt).toISOString()}`
67
+ : `last evaluated ${new Date(s.lastEvaluatedAt).toISOString()}, no events yet — evaluated and quiet, which is different from never run`,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * 6.4 — DELIVERY IS AT-LEAST-ONCE BY DESIGN. Safe for a reader, DOUBLE
73
+ * EXECUTION for an executor: a callback that triggers work must carry a key, or
74
+ * the same merge lands twice. The key is derived from the EVENT, never from the
75
+ * delivery attempt, so a retry produces the same key.
76
+ */
77
+ export const eventKey = (kind: SubKind, target: string, ref: string): string =>
78
+ createHash("sha256").update(`${kind}:${target}:${ref}`).digest("hex").slice(0, 16);
79
+
80
+ export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string };
81
+
82
+ /**
83
+ * 6.2 — EVENTS ARE DERIVED FROM THE RECORD, NEVER PARALLEL TO IT.
84
+ *
85
+ * Enforced here rather than promised in a comment: the event's `ref` must
86
+ * already be present in the record document before anything is emitted. An
87
+ * event stream that can say "task X complete" while DONE.md does not is a
88
+ * second source of truth, and record-vs-state divergence is the defect this
89
+ * fleet hit most this week. ADR-003 keeps markdown authoritative, and this must
90
+ * not quietly reopen it.
91
+ *
92
+ * So the ordering is not a convention: emission READS the record, and an event
93
+ * whose cause is not in the record cannot be emitted at all.
94
+ */
95
+ export function eventIsDerived(recordText: string, ev: RecordEvent): { ok: true } | { ok: false; error: string } {
96
+ if (!ev.ref) return { ok: false, error: `event for ${ev.kind} ${ev.target} carries no ref — nothing ties it to a record entry` };
97
+ if (!String(recordText).includes(ev.ref))
98
+ return {
99
+ ok: false,
100
+ error:
101
+ `refusing to emit ${ev.kind} ${ev.target}: its ref ${ev.ref} is NOT in the record. ` +
102
+ `An event that exists without the record change that caused it is a second source of truth — ` +
103
+ `the stream would claim something the authoritative document does not.`,
104
+ };
105
+ return { ok: true };
106
+ }
107
+
108
+ /** Subscriptions matching an event. Exact target match; no wildcards yet. */
109
+ export const matching = (subs: Subscription[], ev: RecordEvent): Subscription[] =>
110
+ subs.filter((s) => s.kind === ev.kind && s.target === ev.target);
111
+
112
+ export type Delivery = { subscriptionId: string; agentId: string; key: string; status: "delivered" | "duplicate-suppressed" };
113
+
114
+ /**
115
+ * Evaluate every subscription against one event and return what to deliver.
116
+ *
117
+ * EVALUATION IS RECORDED EVEN WHEN NOTHING MATCHES — that is 6.3's whole point.
118
+ * A subscription only learns it is alive by being evaluated, so the mark is
119
+ * written for every subscription of that kind, not only the ones that fired.
120
+ */
121
+ export function evaluate(subs: Subscription[], ev: RecordEvent, now: number): { subs: Subscription[]; deliveries: Delivery[] } {
122
+ const key = eventKey(ev.kind, ev.target, ev.ref);
123
+ const deliveries: Delivery[] = [];
124
+ const next = subs.map((s) => {
125
+ if (s.kind !== ev.kind) return s;
126
+ const evaluated = { ...s, lastEvaluatedAt: now };
127
+ if (s.target !== ev.target) return evaluated;
128
+ if (s.delivered.includes(key)) {
129
+ deliveries.push({ subscriptionId: s.id, agentId: s.agentId, key, status: "duplicate-suppressed" });
130
+ return evaluated;
131
+ }
132
+ deliveries.push({ subscriptionId: s.id, agentId: s.agentId, key, status: "delivered" });
133
+ return { ...evaluated, lastEventAt: now, delivered: [...s.delivered, key].slice(-200) };
134
+ });
135
+ return { subs: next, deliveries };
136
+ }
137
+
138
+ /* ── verbs ─────────────────────────────────────────────────────────────────── */
139
+
140
+ export const subscribeSchema = {
141
+ agentId: z.string().min(1),
142
+ kind: z.enum(["task", "phase", "item", "pr"]),
143
+ target: z.string().min(1),
144
+ };
145
+
146
+ export async function subscribeTool(args: { agentId: string; kind: SubKind; target: string }) {
147
+ const subs = readSubs();
148
+ const dupe = subs.find((s) => s.agentId === args.agentId && s.kind === args.kind && s.target === args.target);
149
+ if (dupe) return { ok: true as const, subscription: dupe, note: "already subscribed — returning the existing registration rather than a second one" };
150
+ const sub: Subscription = {
151
+ id: randomUUID(),
152
+ agentId: args.agentId,
153
+ kind: args.kind,
154
+ target: args.target,
155
+ createdAt: Date.now(),
156
+ lastEvaluatedAt: null,
157
+ lastEventAt: null,
158
+ delivered: [],
159
+ };
160
+ writeSubs([...subs, sub]);
161
+ return { ok: true as const, subscription: sub, health: subscriptionHealth(sub) };
162
+ }
163
+
164
+ export const unsubscribeSchema = { agentId: z.string().min(1), id: z.string().min(1) };
165
+
166
+ export async function unsubscribeTool(args: { agentId: string; id: string }) {
167
+ const subs = readSubs();
168
+ const sub = subs.find((s) => s.id === args.id);
169
+ if (!sub) return { ok: false as const, error: `no subscription '${args.id}'` };
170
+ // Another agent's subscription is not yours to remove: silently dropping
171
+ // someone else's notification is how a miss is manufactured.
172
+ if (sub.agentId !== args.agentId) return { ok: false as const, error: `subscription '${args.id}' belongs to '${sub.agentId}', not '${args.agentId}'` };
173
+ writeSubs(subs.filter((s) => s.id !== args.id));
174
+ return { ok: true as const, removed: sub };
175
+ }
176
+
177
+ export const listSubscriptionsSchema = { agentId: z.string().optional() };
178
+
179
+ export async function listSubscriptionsTool(args: { agentId?: string }) {
180
+ const all = readSubs();
181
+ const subs = args.agentId ? all.filter((s) => s.agentId === args.agentId) : all;
182
+ const rows = subs.map((s) => ({ ...s, health: subscriptionHealth(s) }));
183
+ const neverEvaluated = rows.filter((r) => r.health.level === "error");
184
+ return {
185
+ ok: neverEvaluated.length === 0,
186
+ // Population beside the verdict, always: "no subscriptions" and "none
187
+ // listed for you" are different claims.
188
+ population: { listed: rows.length, total: all.length },
189
+ subscriptions: rows,
190
+ ...(neverEvaluated.length
191
+ ? { error: `${neverEvaluated.length} of ${rows.length} subscription(s) have NEVER been evaluated — they have produced no evidence of being wired to anything.` }
192
+ : {}),
193
+ };
194
+ }
195
+
196
+ /** Persist an evaluation. Callers do this after a record write, never before. */
197
+ export function commitEvaluation(next: Subscription[]): void {
198
+ writeSubs(next);
199
+ }
@@ -0,0 +1,107 @@
1
+ /*
2
+ * Task 8 — a watch over an append-only log, keyed on FILE OFFSET.
3
+ *
4
+ * A WATCH MUST NOT BE ABLE TO CONSUME. This is Task 7.1's rule at its second
5
+ * caller, not a second fix: the read cursor records what the AGENT has taken
6
+ * delivery of, and only the agent reading may advance it. A pusher typing into
7
+ * a pane is a reader; a watch waiting for traffic is a reader; neither may
8
+ * consume on the agent's behalf.
9
+ *
10
+ * So this keeps its OWN position — `lastLineSeen` — and never touches
11
+ * `cursors/<id>.json`. A watch that advanced the read cursor would reproduce
12
+ * exactly the defect Task 7 removed, one component over: the message would be
13
+ * marked consumed by something that only looked at it.
14
+ *
15
+ * WHY THIS DOES NOT VIOLATE 6.2 (8.5). "Events are derived from the record,
16
+ * never parallel to it." The room and inbox JSONL logs ARE the record — they
17
+ * are the authoritative store, not a projection of one. `land`'s events are
18
+ * derived from a document write; these are derived from the log itself. Both
19
+ * read the authoritative artifact and neither maintains a second source of
20
+ * truth. The test for a violation is whether the stream could assert something
21
+ * the record does not, and it cannot: every event here IS a line in the log,
22
+ * identified by its offset in that log.
23
+ *
24
+ * Keyed on OFFSET rather than timestamp or id, because an offset is a property
25
+ * of the log itself. Two lines can share a `ts`, an id can be rewritten, and a
26
+ * clock can disagree with another clock — a line's position cannot.
27
+ */
28
+ import { existsSync, readFileSync } from "node:fs";
29
+ import path from "node:path";
30
+ import { z } from "zod";
31
+ import { ROOT } from "../store.js";
32
+
33
+ export type LogKind = "room" | "inbox";
34
+
35
+ export type LogWatch = {
36
+ id: string;
37
+ agentId: string;
38
+ kind: LogKind;
39
+ target: string;
40
+ createdAt: number;
41
+ /** null until evaluated at least once — never-evaluated is an ERROR (8.3). */
42
+ lastEvaluatedAt: number | null;
43
+ /** The offset this watch has REPORTED up to. Never the read cursor. */
44
+ lastLineSeen: number;
45
+ };
46
+
47
+ export const logFileFor = (kind: LogKind, target: string): string =>
48
+ kind === "room" ? path.join(ROOT, "rooms", `${target}.jsonl`) : path.join(ROOT, "inbox", `${target}.jsonl`);
49
+
50
+ /** Parsed lines of a log. Malformed lines are SKIPPED but still counted. */
51
+ export function readLog(file: string): { entries: unknown[]; total: number } {
52
+ if (!existsSync(file)) return { entries: [], total: 0 };
53
+ const lines = readFileSync(file, "utf8").split("\n").filter((l) => l.trim());
54
+ const entries: unknown[] = [];
55
+ for (const l of lines) {
56
+ try {
57
+ entries.push(JSON.parse(l));
58
+ } catch {
59
+ // A malformed line still OCCUPIES an offset. Skipping it without counting
60
+ // would shift every subsequent line's position by one and silently
61
+ // re-report the whole tail.
62
+ entries.push(null);
63
+ }
64
+ }
65
+ return { entries, total: lines.length };
66
+ }
67
+
68
+ export type LogEvent = { kind: LogKind; target: string; offset: number; line: unknown };
69
+
70
+ /**
71
+ * Lines appended since `lastLineSeen`.
72
+ *
73
+ * A TRUNCATED LOG IS NOT A QUIET ONE. If the file is SHORTER than the watch's
74
+ * position, something rewrote or pruned it — reporting "no new lines" would be
75
+ * indistinguishable from a healthy quiet period. It is surfaced instead.
76
+ */
77
+ export function newLines(
78
+ watch: Pick<LogWatch, "kind" | "target" | "lastLineSeen">,
79
+ log: { entries: unknown[]; total: number },
80
+ ): { events: LogEvent[]; truncated: boolean; total: number } {
81
+ if (log.total < watch.lastLineSeen)
82
+ return { events: [], truncated: true, total: log.total };
83
+ const events: LogEvent[] = [];
84
+ for (let i = watch.lastLineSeen; i < log.total; i++) {
85
+ events.push({ kind: watch.kind, target: watch.target, offset: i, line: log.entries[i] ?? null });
86
+ }
87
+ return { events, truncated: false, total: log.total };
88
+ }
89
+
90
+ /** 8.3 — health, identical in shape to the record subscriptions, plus lastLineSeen. */
91
+ export function watchHealth(w: LogWatch): { level: "ok" | "error"; detail: string } {
92
+ if (w.lastEvaluatedAt === null)
93
+ return {
94
+ level: "error",
95
+ detail: `never evaluated — this watch has produced no evidence it is attached to anything. "No lines yet" and "never ran" are the same output, and only one of them is healthy.`,
96
+ };
97
+ return {
98
+ level: "ok",
99
+ detail: `last evaluated ${new Date(w.lastEvaluatedAt).toISOString()}, reported up to line ${w.lastLineSeen}`,
100
+ };
101
+ }
102
+
103
+ export const logWatchSchema = {
104
+ agentId: z.string().min(1),
105
+ kind: z.enum(["room", "inbox"]),
106
+ target: z.string().min(1),
107
+ };
@@ -7,6 +7,7 @@ import { promises as fsp } from "node:fs";
7
7
  import { spawn, spawnSync } from "node:child_process";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { z } from "zod";
10
+ import { readLog } from "./logwatch.js";
10
11
  import { renderRecord } from "./render.js";
11
12
  import path from "node:path";
12
13
  import {
@@ -660,6 +661,30 @@ export async function waitForMessageTool(args: {
660
661
  // readMessagesTool now filters out for room/status) doesn't return an empty
661
662
  // result — keep waiting until we have something to deliver or time out.
662
663
  while (Date.now() < deadline) {
664
+ // ALREADY-UNREAD CONTENT WAKES THIS IMMEDIATELY.
665
+ //
666
+ // The loop below waits for the file to GROW from the size captured at entry,
667
+ // so anything that arrived before the wait started could not wake it: the
668
+ // agent sat through the full timeout with messages readable the whole time.
669
+ // Seven real posts landed across consecutive 60s waits that all returned
670
+ // `timedOut`, while `read_messages` returned every one.
671
+ //
672
+ // Growth is the wrong question on its own — "is there anything for me" is
673
+ // the right one, and it is also the cheaper check.
674
+ const pending = await readMessagesTool({ agentId: args.agentId, source: args.source, room: args.room });
675
+ if (pending.messages.length > 0) return { ...pending, waited: true, timedOut: false };
676
+
677
+ // 8.4 — THE SAME POSITION THE WATCH USES, not a second mechanism beside it.
678
+ //
679
+ // This compared byte SIZE, which answers "did the file grow" — a proxy for
680
+ // "is there a new line". The watch keys on LINE OFFSET, which is the thing
681
+ // itself, and reusing it means the wait and the watch cannot disagree about
682
+ // what "new" means. A second mechanism beside the first is how two answers
683
+ // to one question start drifting.
684
+ //
685
+ // It still consumes nothing by itself: the offset is read, never written
686
+ // back to the cursor (8.2).
687
+ const startLines = readLog(file).total;
663
688
  const startSize = await fileSize(file);
664
689
  const remaining = deadline - Date.now();
665
690
  if (remaining <= 0) break;
@@ -680,6 +705,10 @@ export async function waitForMessageTool(args: {
680
705
  };
681
706
 
682
707
  const check = async () => {
708
+ // Line count first — a write that changes bytes without adding a line
709
+ // (a rewrite, a truncation) is not new traffic, and size alone reports
710
+ // it as such.
711
+ if (readLog(file).total > startLines) return finish(true);
683
712
  const sz = await fileSize(file);
684
713
  if (sz > startSize) finish(true);
685
714
  };
@@ -27,6 +27,7 @@ import {
27
27
  } from "@davidbalzan/groundwork-seam";
28
28
  import { ensureWorktreeTool } from "./worktrees.js";
29
29
  import { haltState } from "./stall.js";
30
+ import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
30
31
 
31
32
  const QUEUE_DOC = "docs/QUEUE.md";
32
33
  const DONE_DOC = "docs/DONE.md";
@@ -198,6 +199,44 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
198
199
 
199
200
  // ---------- claim ----------
200
201
 
202
+ /**
203
+ * Insert or replace ONE row in the workstreams table, as TEXT.
204
+ *
205
+ * 2.4b: "a verb only fails closed if it is the ONLY path — nothing currently
206
+ * stops a coordinator editing the board directly instead of calling `claim`."
207
+ * `claim` used to RETURN a `boardHunk` for someone to paste by hand, which is
208
+ * the same discipline with a nicer API. Every legacy row on the board today is
209
+ * hand-written, and that is why `stall_check`'s vcs half has nothing to
210
+ * resolve: a pasted row carries a path, a `claim`-written row carries a real
211
+ * branch ref.
212
+ *
213
+ * Text insertion rather than a re-render: the rest of the file stays
214
+ * byte-identical, the same reason `land` appends its DONE line as text. A board
215
+ * this verb rewrote wholesale would be a diff nobody could review.
216
+ */
217
+ export function upsertBoardRow(text: string, agentId: string, row: string): { text: string; action: "inserted" | "replaced" | "unchanged" } {
218
+ const lines = text.split("\n");
219
+ const header = lines.findIndex((l) => /^\|\s*Stream\s*\|/i.test(l));
220
+ if (header === -1) return { text, action: "unchanged" };
221
+ // The table ends at the first line that is not a row.
222
+ let end = header + 1;
223
+ while (end < lines.length && /^\s*\|/.test(lines[end])) end++;
224
+
225
+ // OWNER MATCHED ON THE CELL, NOT ON THE WHOLE LINE. An agent id appearing in
226
+ // a "Last note" cell is not that agent's row — the occurrence-vs-position
227
+ // defect this repo has re-derived at four granularities.
228
+ const ownerOf = (l: string) => (l.split("|")[2] ?? "").replace(/[`*\s]/g, "");
229
+ const existing = lines.findIndex((l, i) => i > header + 1 && i < end && ownerOf(l) === agentId);
230
+
231
+ if (existing !== -1) {
232
+ if (lines[existing] === row) return { text, action: "unchanged" };
233
+ lines[existing] = row;
234
+ return { text: lines.join("\n"), action: "replaced" };
235
+ }
236
+ lines.splice(end, 0, row);
237
+ return { text: lines.join("\n"), action: "inserted" };
238
+ }
239
+
201
240
  export const claimSchema = {
202
241
  project: z.string().min(1),
203
242
  agentId: z.string().min(1),
@@ -205,9 +244,10 @@ export const claimSchema = {
205
244
  repo: z.string().optional(),
206
245
  base: z.string().optional(),
207
246
  task: z.string().optional(),
247
+ write: z.boolean().optional(),
208
248
  };
209
249
 
210
- export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string }) {
250
+ export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean }) {
211
251
  const halt = haltState();
212
252
  if (halt.halted) {
213
253
  return {
@@ -255,12 +295,52 @@ export async function claimTool(args: { project: string; agentId: string; itemId
255
295
  };
256
296
  }
257
297
 
298
+ // A NEW TASK DOES NOT START FROM A STALE TREE.
299
+ //
300
+ // `ensure_worktree` is idempotent and reuses the agent's existing tree, which
301
+ // is right mid-slice (1.3) and wrong here: `claim` MEANS "start something
302
+ // new", and a tree left on last week's base produces the confidently-wrong
303
+ // inventories the worker card warns about, with nothing about the result
304
+ // looking stale.
305
+ //
306
+ // A freshly CREATED tree is cut from origin/<base> and needs no check — this
307
+ // only ever fires on reuse.
308
+ if (!wt.created && wt.atBase === false) {
309
+ return {
310
+ ok: false as const,
311
+ error:
312
+ `cannot claim: the worktree at ${wt.path} is NOT at ${wt.base}` +
313
+ `${wt.behindBy ? ` (${wt.behindBy} commit(s) behind)` : ""} — it is on '${wt.branch}' at ${String(wt.sha).slice(0, 8)}. ` +
314
+ `A new task started here would be based on stale content, and nothing about the result would look stale. ` +
315
+ `Run \`refresh_worktrees\` to fast-forward idle trees, or finish and land the work already in it.`,
316
+ item: { id: item.id, priority: item.priority },
317
+ worktree: { path: wt.path, branch: wt.branch, sha: wt.sha, base: wt.base, atBase: false },
318
+ };
319
+ }
320
+
321
+ const boardHunk = `| ${keyOf(item)} | ${args.agentId} | \`${wt.branch}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
322
+
323
+ // 2.4b — THE VERB WRITES THE ROW. Reported by default, applied with
324
+ // write:true, matching `land`. A returned hunk that a human pastes is the
325
+ // same discipline with a nicer API, and the pasted rows are why the board
326
+ // carries paths where a claim would have carried a resolvable branch ref.
327
+ let board: { action: string; path?: string } = { action: "reported" };
328
+ const b = readDoc(repo, BOARD_DOC);
329
+ if (!b) {
330
+ board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` };
331
+ } else if (args.write) {
332
+ const next = upsertBoardRow(b.text, args.agentId, boardHunk);
333
+ if (next.action !== "unchanged") writeFileSync(path.join(repo, BOARD_DOC), next.text);
334
+ board = { action: next.action, path: BOARD_DOC };
335
+ }
336
+
258
337
  return {
259
338
  ok: true as const,
260
339
  project: args.project,
261
340
  agentId: args.agentId,
262
341
  item: { id: item.id, priority: item.priority, text: item.text },
263
- boardHunk: `| ${keyOf(item)} | ${args.agentId} | \`${wt.branch}\` · ${wt.path} | 🚧 In Progress | — | claimed |`,
342
+ boardHunk,
343
+ board,
264
344
  worktreeEnsured: true,
265
345
  worktree: { path: wt.path, sha: wt.sha, branch: wt.branch, created: wt.created, base: wt.base },
266
346
  };
@@ -404,6 +484,33 @@ export async function landTool(args: {
404
484
  }
405
485
  }
406
486
 
487
+ // 6.2 — EMIT ONLY AS A CONSEQUENCE OF THE RECORD CHANGING.
488
+ //
489
+ // Read back from disk, AFTER the write, and refuse to emit anything whose ref
490
+ // is not there. The ordering is the guarantee: an event cannot exist without
491
+ // the record entry that caused it, because the record is what is consulted to
492
+ // decide whether to emit. An event stream that can say "task X complete"
493
+ // while DONE.md does not is a second source of truth, and record-vs-state
494
+ // divergence is the defect this fleet hit most this week.
495
+ //
496
+ // Reported, never thrown: a delivery failure must not undo a merge that has
497
+ // already happened. `land` is a RECORDER.
498
+ let events: { emitted: RecordEvent[]; deliveries: unknown[]; refused: string[] } = { emitted: [], deliveries: [], refused: [] };
499
+ if (args.write && target) {
500
+ const after = readDoc(repo, DONE_DOC);
501
+ const recordText = after?.text ?? "";
502
+ const ev: RecordEvent = { kind: "item", target: target.id, ref: args.pr, summary: summarize(target.text) };
503
+ const derived = eventIsDerived(recordText, ev);
504
+ if (!derived.ok) {
505
+ events.refused.push(derived.error);
506
+ } else {
507
+ const now = Date.now();
508
+ const { subs, deliveries } = evaluate(readSubs(), ev, now);
509
+ commitEvaluation(subs);
510
+ events = { emitted: [ev], deliveries, refused: [] };
511
+ }
512
+ }
513
+
407
514
  return {
408
515
  ok: true as const,
409
516
  project: args.project,
@@ -411,6 +518,7 @@ export async function landTool(args: {
411
518
  comparedAgainst: ref,
412
519
  landedIn: landedIn.slice(0, 8),
413
520
  queueItem: target ? { id: target.id, closed: true, textUnchanged: true } : null,
521
+ events,
414
522
  candidates: target
415
523
  ? undefined
416
524
  : candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
@@ -259,10 +259,29 @@ export async function listAgentsTool() {
259
259
 
260
260
  const reg = await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
261
261
  for (const [id, entry] of Object.entries(current)) {
262
- if (liveTransports.has(id)) {
263
- entry.lastHeartbeat = now;
264
- continue;
265
- }
262
+ // A LIVE TRANSPORT PROTECTS AN AGENT FROM EVICTION. IT DOES NOT STAMP IT.
263
+ //
264
+ // This loop used to write `entry.lastHeartbeat = now` here, and
265
+ // `agents.json` is ONE store shared by every fleet on the machine while
266
+ // `list_agents` takes no project argument — so a call from one fleet
267
+ // rewrote every other fleet's timestamps. Another fleet did not ask for
268
+ // that, cannot see it, and it is not ours to write.
269
+ //
270
+ // kit#105 removed the fabricated value from the RESPONSE; the WRITE
271
+ // stayed, so every consumer reading the file directly still saw
272
+ // freshness this call had invented.
273
+ //
274
+ // AND IT MASKED STALL DETECTION. `stall_check` reads `lastHeartbeat` to
275
+ // find agents that have gone quiet. Because any `list_agents` call
276
+ // refreshed every live-transport agent, that clock could never age past
277
+ // the threshold, so no-heartbeat could not fire for exactly the agents
278
+ // most likely to be stuck — alive, attached, and doing nothing.
279
+ //
280
+ // Nothing is lost: the pusher calls `heartbeat` every 60s
281
+ // (scripts/coord-pusher.mjs:181), so an attached agent has a REAL
282
+ // heartbeat. This stamp only ever overwrote a true value with a
283
+ // simultaneous one.
284
+ if (liveTransports.has(id)) continue;
266
285
  if (now - entry.lastHeartbeat > EVICT_MS) {
267
286
  evicted.push(id);
268
287
  delete current[id];