agent-coord-mcp 0.26.34 → 0.26.36

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.
@@ -16,6 +16,7 @@ import {
16
16
  listCursorFiles,
17
17
  listInboxFiles,
18
18
  listReceiptFiles,
19
+ moveCursorOffset,
19
20
  normalizeRoom,
20
21
  pruneHistory,
21
22
  readJson,
@@ -23,6 +24,7 @@ import {
23
24
  rewriteJsonl,
24
25
  roomFile,
25
26
  updateJson,
27
+ type ArchiveResult,
26
28
  type RoomRegistry,
27
29
  } from "../store.js";
28
30
  import {
@@ -60,48 +62,59 @@ function keepEntry(
60
62
  return isDecision(e) ? e.ts > decisionCutoff : e.ts > cutoff;
61
63
  }
62
64
 
63
- // Shift every agent's cursor offsets down by the number of entries removed
64
- // ahead of them (append-only + oldest-first removal ⇒ plain subtraction,
65
- // clamped at 0). Shared by prune and live compaction — without this, an
66
- // offset past the new file length makes read_messages return [] forever.
67
- export async function adjustCursors(removals: {
68
- roomRemovedByChan?: Record<string, number>;
69
- statusRemoved?: number;
70
- perAgentInboxRemoved?: Record<string, number>;
65
+ /**
66
+ * Carry every agent's cursors across a rewrite of the streams they index. Shared by prune and
67
+ * live compaction — without this, an offset past the new file length makes read_messages return
68
+ * [] forever.
69
+ *
70
+ * ⛔ EACH CURSOR IS MOVED IN ITS OWN UNIT (⟨q-f44f17a7⟩). A READ cursor (`<agent>.json`) counts
71
+ * entries: it is shifted down by the entries removed (clamped at 0; at-least-once if kept
72
+ * decisions sat behind it). A PUSH cursor (`<agent>.push.json`) is a BYTE offset: it is mapped
73
+ * through the archive pass's `rebaseBytes`, exactly. This took three visits to one function:
74
+ * ⟨q-40449919⟩ fixed the push FILENAME and left the unit wrong, and ⟨q-c36a078f⟩ fixed the same
75
+ * confusion in push-cursor seeding. So the caller cannot pass a bare count any more — it passes the
76
+ * whole `ArchiveResult`, which carries both units, and the choice between them is made HERE by the
77
+ * cursor's own kind rather than trusted to every call site.
78
+ */
79
+ export type StreamRewrite = Pick<ArchiveResult, "removed" | "rebaseBytes">;
80
+ export async function adjustCursors(rewrites: {
81
+ roomByChan?: Record<string, StreamRewrite>;
82
+ status?: StreamRewrite;
83
+ inboxByAgent?: Record<string, StreamRewrite>;
71
84
  }): Promise<string[]> {
72
- const roomRemovedByChan = removals.roomRemovedByChan ?? {};
73
- const statusRemoved = removals.statusRemoved ?? 0;
74
- const perAgentInboxRemoved = removals.perAgentInboxRemoved ?? {};
85
+ const roomByChan = rewrites.roomByChan ?? {};
86
+ const inboxByAgent = rewrites.inboxByAgent ?? {};
75
87
  const cursorsAdjusted: string[] = [];
76
88
  for (const cname of await listCursorFiles()) {
77
89
  // q-40449919: this was `cname.replace(/\.json$/, "")`, which strips only
78
90
  // the outer extension. A push cursor's `<agent>.push.json` then keyed
79
- // `perAgentInboxRemoved` lookups below on `<agent>.push` — a key that
91
+ // `inboxByAgent` lookups below on `<agent>.push` — a key that
80
92
  // never matches — so a push cursor's inboxOffset was silently never
81
- // decremented after a prune shrank the inbox, drifting it toward a
82
- // false past-EOF the next time doctor ran.
83
- const { id } = agentIdFromCursorFilename(cname);
93
+ // adjusted after a prune shrank the inbox.
94
+ const { id, kind } = agentIdFromCursorFilename(cname);
95
+ const move = (offset: number, rw: StreamRewrite) => moveCursorOffset(kind, offset, rw);
84
96
  const cursorPath = path.join(CURSOR_DIR, cname);
85
97
  let touched = false;
86
98
  await updateJson<Cursor>(cursorPath, {}, (current) => {
87
- for (const [chan, removed] of Object.entries(roomRemovedByChan)) {
99
+ for (const [chan, rw] of Object.entries(roomByChan)) {
100
+ if (rw.removed <= 0) continue;
88
101
  if (chan === DEFAULT_ROOM) {
89
102
  if (current.roomOffset !== undefined) {
90
- current.roomOffset = Math.max(0, current.roomOffset - removed);
103
+ current.roomOffset = move(current.roomOffset, rw);
91
104
  touched = true;
92
105
  }
93
106
  } else if (current.roomOffsets?.[chan] !== undefined) {
94
- current.roomOffsets[chan] = Math.max(0, current.roomOffsets[chan] - removed);
107
+ current.roomOffsets[chan] = move(current.roomOffsets[chan], rw);
95
108
  touched = true;
96
109
  }
97
110
  }
98
- if (current.statusOffset !== undefined && statusRemoved > 0) {
99
- current.statusOffset = Math.max(0, current.statusOffset - statusRemoved);
111
+ if (current.statusOffset !== undefined && rewrites.status && rewrites.status.removed > 0) {
112
+ current.statusOffset = move(current.statusOffset, rewrites.status);
100
113
  touched = true;
101
114
  }
102
- const myInboxRemoved = perAgentInboxRemoved[id] ?? 0;
103
- if (current.inboxOffset !== undefined && myInboxRemoved > 0) {
104
- current.inboxOffset = Math.max(0, current.inboxOffset - myInboxRemoved);
115
+ const myInbox = inboxByAgent[id];
116
+ if (current.inboxOffset !== undefined && myInbox && myInbox.removed > 0) {
117
+ current.inboxOffset = move(current.inboxOffset, myInbox);
105
118
  touched = true;
106
119
  }
107
120
  return current;
@@ -232,26 +245,26 @@ export async function pruneTool(args: {
232
245
  // Per-channel removed counts so we can shift the matching offset in each
233
246
  // cursor (default channel → roomOffset, others → roomOffsets[chan]).
234
247
  // Aged-out entries are archived to archive/rooms/<chan>.jsonl, never deleted.
235
- const roomRemovedByChan: Record<string, number> = {};
248
+ const roomByChan: Record<string, StreamRewrite> = {};
236
249
  let roomRemovedTotal = 0;
237
250
  if (targets.has("rooms")) {
238
251
  for (const chan of channels) {
239
252
  const r = await archiveJsonl<Message>(roomFile(chan), archiveRoomFile(chan), keep);
240
253
  if (r.removed > 0) {
241
- roomRemovedByChan[chan] = r.removed;
254
+ roomByChan[chan] = r;
242
255
  roomRemovedTotal += r.removed;
243
256
  }
244
257
  }
245
258
  }
246
259
  const statusResult = targets.has("status")
247
260
  ? await archiveJsonl<StatusEntry>(STATUS_FILE, ARCHIVE_STATUS_FILE, (e) => e.ts > cutoff)
248
- : { kept: 0, removed: 0, archived: 0 };
261
+ : { kept: 0, removed: 0, archived: 0, rebaseBytes: () => 0 };
249
262
 
250
263
  let inboxRemoved = 0;
251
264
  const deletedOrphans: string[] = [];
252
- // perAgentInboxRemoved: how many entries were stripped from each *kept* agent's
253
- // inbox, so we can shift only that agent's inboxOffset cursor below.
254
- const perAgentInboxRemoved: Record<string, number> = {};
265
+ // inboxByAgent: what was stripped from each *kept* agent's inbox (in both
266
+ // units), so we can shift only that agent's inboxOffset cursors below.
267
+ const inboxByAgent: Record<string, StreamRewrite> = {};
255
268
  if (targets.has("inbox")) {
256
269
  for (const fname of await listInboxFiles()) {
257
270
  const id = fname.replace(/\.jsonl$/, "");
@@ -266,14 +279,14 @@ export async function pruneTool(args: {
266
279
  }
267
280
  const r = await archiveJsonl<Message>(filePath, archiveInboxFile(id), (e) => e.ts > cutoff);
268
281
  inboxRemoved += r.removed;
269
- perAgentInboxRemoved[id] = r.removed;
282
+ inboxByAgent[id] = r;
270
283
  }
271
284
  }
272
285
 
273
286
  const cursorsAdjusted = await adjustCursors({
274
- roomRemovedByChan,
275
- statusRemoved: statusResult.removed,
276
- perAgentInboxRemoved,
287
+ roomByChan,
288
+ status: statusResult,
289
+ inboxByAgent,
277
290
  });
278
291
 
279
292
  // Membership sweep: drop orphans (not in registry) and stale members (no
@@ -41,7 +41,7 @@ import { readFileSync, statSync } from "node:fs";
41
41
  import { inboxFile, roomFile, getRooms, transportFile } from "../store.js";
42
42
  import { activeTransport, HERDR, targetOf, type Transport, type TransportMarker } from "../transports/index.js";
43
43
  import { renderForPane, advancePushCursor, readPushCursorFor, type Message } from "./herdr-delivery.js";
44
- import { newMessagesIn } from "./jsonl-offsets.js";
44
+ import { newMessagesIn, idEndingAt, endOfId } from "./jsonl-offsets.js";
45
45
 
46
46
  export const DEFAULT_POLL_MS = 1000;
47
47
  /** Marker and room membership are re-read this often. A room joined mid-session is picked up within this many ticks. */
@@ -61,6 +61,12 @@ export type TailContext = {
61
61
  rooms: string[];
62
62
  /** In-process high-water mark per source: never re-type below this, even if the persistent cursor could not be written. */
63
63
  hw: Map<string, number>;
64
+ /**
65
+ * The id of the message each `hw` mark was set just past (null: set with no message, e.g. an EOF
66
+ * seed of a file whose last line has no id). A byte mark means something only while THAT message
67
+ * still ends there — see the rewrite check in tailOnce.
68
+ */
69
+ hwId: Map<string, string | null>;
64
70
  /** Last seen size per source: a source that has not grown costs one stat. */
65
71
  sizes: Map<string, number>;
66
72
  /**
@@ -73,10 +79,17 @@ export type TailContext = {
73
79
  seedEof: boolean;
74
80
  /** Sources already seeded — a room discovered on a later refresh is seeded on ITS first sight, not skipped. */
75
81
  seeded: Set<string>;
82
+ /** Sources whose persisted cursor is past EOF right now — logged once on entry, not on every tick. */
83
+ pastEof: Set<string>;
76
84
  };
77
- export const newContext = (seedEof = false): TailContext => ({ tick: 0, marker: null, rooms: [], hw: new Map(), sizes: new Map(), seedEof, seeded: new Set() });
85
+ export const newContext = (seedEof = false): TailContext => ({ tick: 0, marker: null, rooms: [], hw: new Map(), hwId: new Map(), sizes: new Map(), seedEof, seeded: new Set(), pastEof: new Set() });
78
86
 
79
87
  const keyOf = (s: { kind: string; chan?: string }) => (s.kind === "dm" ? "dm" : `room:${s.chan}`);
88
+ /** Set the in-process mark just past `msg`, remembering which message it follows. */
89
+ const mark = (ctx: TailContext, key: string, end: number, msg: Record<string, unknown>) => {
90
+ ctx.hw.set(key, end);
91
+ ctx.hwId.set(key, msg.id === undefined || msg.id === null ? null : String(msg.id));
92
+ };
80
93
  const sizeOf = (file: string): number => {
81
94
  try { return statSync(file).size; } catch { return 0; }
82
95
  };
@@ -169,10 +182,34 @@ export async function tailOnce(
169
182
  if (ctx.seedEof && !ctx.seeded.has(key)) {
170
183
  ctx.seeded.add(key);
171
184
  ctx.hw.set(key, size);
185
+ ctx.hwId.set(key, idEndingAt(src.file, size));
172
186
  log(`[herdr-tail] seeded ${key} at EOF (${size} bytes) for ${agentId} — first sight this process, nothing predating the tail will be typed`);
173
187
  continue;
174
188
  }
189
+ // ⛔ A BYTE MARK IS ONLY VALID WHILE THE SAME MESSAGE STILL ENDS THERE (⟨q-f44f17a7⟩). Any sender's
190
+ // server compacts a room on send, and every byte after the cut then means something else. A mark
191
+ // left PAST the new EOF made the seat deaf until the room regrew past it (the 40-minute blind
192
+ // stretch of 2026-09-24); a mark left INSIDE the new file resumes mid-stream and skips whatever
193
+ // now sits below it. So the mark is checked against the message it was set after: moved to where
194
+ // that message now ends, or — if it was archived — dropped, and the PERSISTED cursor, which the
195
+ // compactor rebased exactly (adjustCursors → rebaseBytes), is the position again.
196
+ const held = ctx.hw.get(key);
197
+ if (held !== undefined && idEndingAt(src.file, held) !== (ctx.hwId.get(key) ?? null)) {
198
+ const id = ctx.hwId.get(key);
199
+ const moved = id ? endOfId(src.file, id) : null;
200
+ log(`[herdr-tail] ${key} for ${agentId} was rewritten beneath the in-process mark (byte ${held}, after ${id ?? "no id"}) — ${moved !== null ? `moved to byte ${moved}, where that message now ends` : "that message is gone; resuming from the rebased push cursor"}`);
201
+ if (moved !== null) ctx.hw.set(key, moved);
202
+ else { ctx.hw.delete(key); ctx.hwId.delete(key); }
203
+ }
175
204
  const from = await offsetOf(agentId, src, ctx);
205
+ // …and if the PERSISTED cursor is past EOF too, the compactor has not rebased it yet (it rewrites
206
+ // the file first, then the cursors) or a server without this fix compacted. `size <= from` below
207
+ // skips it either way; this only makes the state VISIBLE, once per entry — on 2026-09-24 the only
208
+ // measurements of this defect came from a seat reading the room file by hand.
209
+ if (from > size) {
210
+ if (!ctx.pastEof.has(key)) log(`[herdr-tail] ${key} push cursor for ${agentId} is past EOF (${from} > ${size} bytes) — nothing can be typed from there until it is rebased or the file regrows past it (q-f44f17a7)`);
211
+ ctx.pastEof.add(key);
212
+ } else ctx.pastEof.delete(key);
176
213
  // ⭐ NEGATIVE CONTROL FOR THE CLASS THIS ROW IS ABOUT: nothing is logged here when
177
214
  // `size === from` — a source with nothing owed must stay silent, or every idle room on
178
215
  // every tick becomes a line and the log that is supposed to make a real stall visible
@@ -180,7 +217,7 @@ export async function tailOnce(
180
217
  if (size <= from) continue;
181
218
 
182
219
  for (const { msg, end } of newMessagesIn(src.file, from)) {
183
- if (msg.from === agentId) { ctx.hw.set(key, end); continue; }
220
+ if (msg.from === agentId) { mark(ctx, key, end, msg); continue; }
184
221
  const where = src.kind === "dm" ? ({ kind: "dm" } as const) : ({ kind: "room", chan: src.chan! } as const);
185
222
  const rendered = await renderForPane(msg as unknown as Message, agentId, where);
186
223
  let r: { delivered: boolean; error?: string; verified?: boolean; safeToRetry?: boolean; outcome?: string };
@@ -207,13 +244,13 @@ export async function tailOnce(
207
244
  // re-typing a message that may already be sitting in the box is the paste loop in another coat.
208
245
  // A throw lands here too: nothing says whether a key went out before it.
209
246
  if (!r.delivered || r.verified !== true) {
210
- ctx.hw.set(key, end);
247
+ mark(ctx, key, end, msg);
211
248
  out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: `${r.outcome ?? (r.delivered ? "unverified" : "not delivered")} — ${r.error ?? "not verified"}; the persistent cursor was not moved (q-15d763dc)` });
212
249
  break;
213
250
  }
214
251
  // It landed and was verified. Record that in-process FIRST, so a cursor write that fails
215
252
  // cannot turn one delivery into a paste loop, then persist exactly past this line.
216
- ctx.hw.set(key, end);
253
+ mark(ctx, key, end, msg);
217
254
  const persisted = await advancePushCursor(agentId, where, end);
218
255
  out.delivered.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? "") });
219
256
  if (!persisted) {
@@ -27,3 +27,26 @@ export function newMessagesIn(file: string, from: number): OffsetMessage[] {
27
27
  }
28
28
  return out;
29
29
  }
30
+
31
+ /**
32
+ * The id of the message whose line ends EXACTLY at byte `end`, or null (no such line, past EOF, not
33
+ * JSON, no id). What makes a byte offset checkable: a position is only still valid while the SAME
34
+ * message ends there — a compaction rewrites the file and every byte after the cut then means
35
+ * something else (⟨q-f44f17a7⟩).
36
+ */
37
+ export function idEndingAt(file: string, end: number): string | null {
38
+ let buf: Buffer;
39
+ try { buf = readFileSync(file); } catch { return null; }
40
+ if (end <= 0 || end > buf.length || buf[end - 1] !== 0x0a) return null;
41
+ const start = end >= 2 ? buf.lastIndexOf(0x0a, end - 2) + 1 : 0;
42
+ try {
43
+ const id = (JSON.parse(buf.subarray(start, end - 1).toString("utf8")) as { id?: unknown }).id;
44
+ return id === undefined || id === null ? null : String(id);
45
+ } catch { return null; }
46
+ }
47
+
48
+ /** The byte just past the line of the message with this id, or null if the file no longer holds it. */
49
+ export function endOfId(file: string, id: string): number | null {
50
+ for (const { msg, end } of newMessagesIn(file, 0)) if (String(msg.id ?? "") === id) return end;
51
+ return null;
52
+ }
@@ -549,7 +549,7 @@ async function maybeCompactRoom(chan: string): Promise<void> {
549
549
  archiveRoomFile(chan),
550
550
  (e) => e.ts >= boundaryTs || (isDecision(e) && e.ts > decisionCutoff)
551
551
  );
552
- if (r.removed > 0) await adjustCursors({ roomRemovedByChan: { [chan]: r.removed } });
552
+ if (r.removed > 0) await adjustCursors({ roomByChan: { [chan]: r } });
553
553
  }
554
554
 
555
555
  async function maybeCompactStatus(): Promise<void> {
@@ -562,7 +562,7 @@ async function maybeCompactStatus(): Promise<void> {
562
562
  ARCHIVE_STATUS_FILE,
563
563
  (e) => e.ts >= boundaryTs
564
564
  );
565
- if (r.removed > 0) await adjustCursors({ statusRemoved: r.removed });
565
+ if (r.removed > 0) await adjustCursors({ status: r });
566
566
  }
567
567
 
568
568
  // ---------- read_messages ----------