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.
- package/dist/capabilities.js +23 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/store.js +75 -26
- package/dist/store.js.map +1 -1
- package/dist/tools/admin.js +28 -31
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/herdr-tail.js +41 -5
- package/dist/tools/herdr-tail.js.map +1 -1
- package/dist/tools/jsonl-offsets.js +32 -0
- package/dist/tools/jsonl-offsets.js.map +1 -1
- package/dist/tools/messaging.js +2 -2
- package/dist/tools/messaging.js.map +1 -1
- package/package.json +1 -1
- package/src/capabilities.ts +23 -1
- package/src/store.ts +86 -24
- package/src/tools/admin.ts +46 -33
- package/src/tools/herdr-tail.ts +42 -5
- package/src/tools/jsonl-offsets.ts +23 -0
- package/src/tools/messaging.ts +2 -2
package/src/tools/admin.ts
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
73
|
-
const
|
|
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
|
-
// `
|
|
91
|
+
// `inboxByAgent` lookups below on `<agent>.push` — a key that
|
|
80
92
|
// never matches — so a push cursor's inboxOffset was silently never
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
const
|
|
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,
|
|
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 =
|
|
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] =
|
|
107
|
+
current.roomOffsets[chan] = move(current.roomOffsets[chan], rw);
|
|
95
108
|
touched = true;
|
|
96
109
|
}
|
|
97
110
|
}
|
|
98
|
-
if (current.statusOffset !== undefined &&
|
|
99
|
-
current.statusOffset =
|
|
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
|
|
103
|
-
if (current.inboxOffset !== undefined &&
|
|
104
|
-
current.inboxOffset =
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
253
|
-
//
|
|
254
|
-
const
|
|
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
|
-
|
|
282
|
+
inboxByAgent[id] = r;
|
|
270
283
|
}
|
|
271
284
|
}
|
|
272
285
|
|
|
273
286
|
const cursorsAdjusted = await adjustCursors({
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
287
|
+
roomByChan,
|
|
288
|
+
status: statusResult,
|
|
289
|
+
inboxByAgent,
|
|
277
290
|
});
|
|
278
291
|
|
|
279
292
|
// Membership sweep: drop orphans (not in registry) and stale members (no
|
package/src/tools/herdr-tail.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/tools/messaging.ts
CHANGED
|
@@ -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({
|
|
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({
|
|
565
|
+
if (r.removed > 0) await adjustCursors({ status: r });
|
|
566
566
|
}
|
|
567
567
|
|
|
568
568
|
// ---------- read_messages ----------
|