agent-coord-mcp 0.26.34 → 0.26.39

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.
Files changed (47) hide show
  1. package/dist/auth-faults.js +145 -0
  2. package/dist/auth-faults.js.map +1 -0
  3. package/dist/capabilities.js +98 -1
  4. package/dist/capabilities.js.map +1 -1
  5. package/dist/launch-unit.js +282 -0
  6. package/dist/launch-unit.js.map +1 -0
  7. package/dist/server.js +46 -7
  8. package/dist/server.js.map +1 -1
  9. package/dist/store.js +214 -27
  10. package/dist/store.js.map +1 -1
  11. package/dist/test-count-key.js +22 -0
  12. package/dist/test-count-key.js.map +1 -0
  13. package/dist/tools/admin.js +28 -31
  14. package/dist/tools/admin.js.map +1 -1
  15. package/dist/tools/events.js +77 -3
  16. package/dist/tools/events.js.map +1 -1
  17. package/dist/tools/herdr-delivery.js +51 -36
  18. package/dist/tools/herdr-delivery.js.map +1 -1
  19. package/dist/tools/herdr-tail.js +115 -57
  20. package/dist/tools/herdr-tail.js.map +1 -1
  21. package/dist/tools/jsonl-offsets.js +32 -0
  22. package/dist/tools/jsonl-offsets.js.map +1 -1
  23. package/dist/tools/messaging.js +2 -2
  24. package/dist/tools/messaging.js.map +1 -1
  25. package/dist/tools/registry.js +50 -3
  26. package/dist/tools/registry.js.map +1 -1
  27. package/dist/transports/herdr.js +92 -2
  28. package/dist/transports/herdr.js.map +1 -1
  29. package/package.json +1 -1
  30. package/scripts/check-test-count.mjs +80 -6
  31. package/scripts/coord-attention-clock.mjs +19 -21
  32. package/scripts/coord-stall-clock.mjs +23 -24
  33. package/scripts/lib/clock-unit-cli.mjs +134 -0
  34. package/src/auth-faults.ts +166 -0
  35. package/src/capabilities.ts +100 -1
  36. package/src/launch-unit.ts +350 -0
  37. package/src/server.ts +47 -6
  38. package/src/store.ts +232 -25
  39. package/src/test-count-key.ts +21 -0
  40. package/src/tools/admin.ts +46 -33
  41. package/src/tools/events.ts +89 -4
  42. package/src/tools/herdr-delivery.ts +46 -31
  43. package/src/tools/herdr-tail.ts +108 -52
  44. package/src/tools/jsonl-offsets.ts +23 -0
  45. package/src/tools/messaging.ts +2 -2
  46. package/src/tools/registry.ts +55 -3
  47. package/src/transports/herdr.ts +93 -2
@@ -23,11 +23,31 @@ const subsFile = () => path.join(ROOT, "subscriptions.json");
23
23
  // only if the enum cannot be widened without an emitter.
24
24
  export { EVENT_KINDS, EVENT_KIND_IDS };
25
25
  export type { RecordEvent, SubKind };
26
+ /**
27
+ * ⟨q-8e00b367⟩(a) — HOW WIDELY A SUBSCRIPTION IS ASKING, as an EXPLICIT FIELD.
28
+ *
29
+ * ⛔ THE ALTERNATIVE WAS AN IN-BAND SENTINEL AND IT IS FORBIDDEN BY DESIGN. `target: "*"` would make
30
+ * one string mean two things — a literal target that happens to be an asterisk, and "everything" —
31
+ * and nothing in the wire shape would say which was meant. A separate field cannot be ambiguous.
32
+ *
33
+ * ⚠ `prefix` IS A PREFIX OF WHATEVER `target` HOLDS FOR THAT KIND, which differs per kind and is not
34
+ * guessable from the name: `pr` → the full ref (`owner/repo#402`), `item` → the queue id, `task` →
35
+ * the KEY (its `ref` is the ticked LINE, so a prefix here is NOT a prefix of the line), `phase` →
36
+ * the phase. Measured from the emitters in record-events.ts, not from documentation.
37
+ */
38
+ export const SUB_SCOPES = ["exact", "kind", "prefix"] as const;
39
+ export type SubScope = (typeof SUB_SCOPES)[number];
40
+
26
41
  export type Subscription = {
27
42
  id: string;
28
43
  agentId: string;
29
44
  kind: SubKind;
30
45
  target: string;
46
+ /**
47
+ * Absent ⇒ `"exact"`, which is what every subscription stored before this field meant. The
48
+ * widening is ADDITIVE: no migration, and no stored registration changes meaning.
49
+ */
50
+ scope?: SubScope;
31
51
  createdAt: number;
32
52
  /**
33
53
  * null until an event OF THIS KIND was evaluated against this subscription.
@@ -195,9 +215,33 @@ export function eventIsDerived(recordText: string, ev: RecordEvent): { ok: true
195
215
  return { ok: true };
196
216
  }
197
217
 
198
- /** Subscriptions matching an event. Exact target match; no wildcards yet. */
218
+ /**
219
+ * Subscriptions matching an event, by the scope each one asked for.
220
+ *
221
+ * ⛔ THE GAP THIS CLOSES IS A DELIVERY OF NOTHING REPORTED AS A DELIVERY. With exact-only matching,
222
+ * "tell me when a new PR arrives" is inexpressible — a `pr` event's `target` IS its ref, so you would
223
+ * have to name `owner/repo#402` at subscribe time, and the entire point is that you do not know it
224
+ * yet. A poller could then scan correctly, emit a well-formed event, and match ZERO subscriptions
225
+ * while every instrument reported success: the tick exits 0, the scan finds a change, the emit
226
+ * succeeds, `list_subscriptions` shows healthy watches. Nothing anywhere reads as wrong.
227
+ *
228
+ * ⚠ `exact` REMAINS THE DEFAULT, so a widened match is always something a subscriber opted into.
229
+ */
199
230
  export const matching = (subs: Subscription[], ev: RecordEvent): Subscription[] =>
200
- subs.filter((s) => s.kind === ev.kind && s.target === ev.target);
231
+ subs.filter((s) => {
232
+ if (s.kind !== ev.kind) return false;
233
+ switch (s.scope ?? "exact") {
234
+ case "kind":
235
+ return true;
236
+ // `startsWith` on a non-empty prefix only: an EMPTY prefix matches every target, which is the
237
+ // `"*"` sentinel this design rejected, arriving through the field that replaced it. `subscribe`
238
+ // refuses to create one; this guard means a hand-edited store cannot smuggle one in either.
239
+ case "prefix":
240
+ return s.target !== "" && ev.target.startsWith(s.target);
241
+ default:
242
+ return s.target === ev.target;
243
+ }
244
+ });
201
245
 
202
246
  export type Delivery = { subscriptionId: string; agentId: string; key: string; status: "delivered" | "duplicate-suppressed" };
203
247
 
@@ -244,18 +288,59 @@ export function evaluate(subs: Subscription[], ev: RecordEvent, now: number): {
244
288
  export const subscribeSchema = {
245
289
  agentId: z.string().min(1),
246
290
  kind: z.enum(EVENT_KIND_IDS),
291
+ // ⚠ STILL REQUIRED AND STILL `min(1)`. `scope:"kind"` does not relax it — it REFUSES it (below), so
292
+ // there is no shape where an empty or absent target is accepted. Relaxing the schema instead would
293
+ // have made `target:""` representable, which is the wildcard this design exists to keep out.
247
294
  target: z.string().min(1),
295
+ // A CLOSED SET, never a free string. The registry generates the kind enum so that a subscription
296
+ // which no emitter can satisfy cannot be registered; a free-form scope would reopen exactly that
297
+ // hole one field over — an unsatisfiable scope is an unsatisfiable subscription.
298
+ scope: z.enum(SUB_SCOPES).optional(),
248
299
  };
249
300
 
250
- export async function subscribeTool(args: { agentId: string; kind: SubKind; target: string }) {
301
+ export async function subscribeTool(args: { agentId: string; kind: SubKind; target: string; scope?: SubScope }) {
302
+ const scope: SubScope = args.scope ?? "exact";
303
+
304
+ // ⛔ `kind` + A TARGET IS REFUSED, NOT IGNORED. An accepted-and-ignored target leaves a
305
+ // subscription that READS NARROWER THAN IT BEHAVES: an inspector sees one specific target and
306
+ // concludes the watch is narrow, while it fires on everything of that kind.
307
+ //
308
+ // ⭐ AND THE ASYMMETRY IS WHAT DECIDES IT RATHER THAN TASTE: refusing is relaxable later, whereas
309
+ // silently-ignoring is NOT tightenable later — the moment such subscriptions exist on disk,
310
+ // tightening changes what an already-stored registration means.
311
+ if (args.scope === "kind" && args.target)
312
+ return {
313
+ ok: false as const,
314
+ error:
315
+ `scope "kind" watches EVERY ${args.kind} event, so the target '${args.target}' would be ignored — refusing rather than ` +
316
+ `accepting a subscription that reads narrower than it behaves. Omit the target for kind-wide, ` +
317
+ `or use scope "prefix" with '${args.target}' to watch the ${args.kind} events whose target starts with it.`,
318
+ };
319
+
320
+ // ⛔ AN EMPTY PREFIX IS THE `"*"` WILDCARD WEARING THE FIELD THAT REPLACED IT. `"".startsWith()` is
321
+ // true of every string, so `scope:"prefix"` with an empty target subscribes to everything of the
322
+ // kind while LOOKING like a narrow, targeted watch — the same back door as `target:"*"`, one layer
323
+ // down and through an approved field. `kind` is how you ask for everything, and it says so.
324
+ if (scope === "prefix" && args.target.trim() === "")
325
+ return {
326
+ ok: false as const,
327
+ error:
328
+ `scope "prefix" with an empty target matches EVERY ${args.kind} event — that is the wildcard this ` +
329
+ `field exists to avoid spelling in-band. Use scope "kind" to watch all of them explicitly, or ` +
330
+ `give a non-empty prefix (e.g. 'davidbalzan/groundwork-kit#' for one repo's PRs).`,
331
+ };
332
+
251
333
  const subs = readSubs();
252
- const dupe = subs.find((s) => s.agentId === args.agentId && s.kind === args.kind && s.target === args.target);
334
+ // Identity includes the SCOPE: `exact` on 'q-1' and `prefix` on 'q-1' are different watches, and
335
+ // collapsing them would silently hand back the narrower one.
336
+ const dupe = subs.find((s) => s.agentId === args.agentId && s.kind === args.kind && s.target === args.target && (s.scope ?? "exact") === scope);
253
337
  if (dupe) return { ok: true as const, subscription: dupe, note: "already subscribed — returning the existing registration rather than a second one" };
254
338
  const sub: Subscription = {
255
339
  id: randomUUID(),
256
340
  agentId: args.agentId,
257
341
  kind: args.kind,
258
342
  target: args.target,
343
+ scope,
259
344
  createdAt: Date.now(),
260
345
  // EXPLICIT null, never left absent. `null` means "we know no scan has run
261
346
  // for this"; `undefined` means "this record predates the field and we
@@ -23,8 +23,8 @@
23
23
  import { mkdirSync, statSync } from "node:fs";
24
24
  import path from "node:path";
25
25
  import { pathToFileURL } from "node:url";
26
- import { activeTransport, HERDR, type TransportMarker } from "../transports/index.js";
27
- import { ROOT, inboxFile, roomFile } from "../store.js";
26
+ import { activeTransport, HERDR, targetOf, type TransportMarker } from "../transports/index.js";
27
+ import { ROOT, inboxFile, roomFile, withDeliveryLock } from "../store.js";
28
28
  import { loadLiveTransports } from "./registry.js";
29
29
  import { newMessagesIn } from "./jsonl-offsets.js";
30
30
  import type { Message } from "./shared.js";
@@ -46,6 +46,8 @@ const safeId = (id: string) => id.replace(/[^A-Za-z0-9._-]/g, "_");
46
46
 
47
47
 
48
48
  export type Where = { kind: "dm" } | { kind: "room"; chan: string };
49
+ /** The (seat, source) key the delivery lock is taken on — ONE spelling, shared by the send-time path and the tail. */
50
+ export const sourceKey = (where: Where) => (where.kind === "dm" ? "dm" : `room:${where.chan}`);
49
51
 
50
52
  /**
51
53
  * ⭐ ONE RENDERER AND ONE CURSOR RULE, SHARED BY BOTH PATHS. The send-time path
@@ -102,9 +104,15 @@ export async function readPushCursorFor(agentId: string): Promise<Record<string,
102
104
  }
103
105
 
104
106
  export type HerdrDeliveryOutcome = { agentId: string; delivered: boolean; enters?: number; error?: string; cursorAdvanced: boolean; deferred?: string };
107
+ /** Every push, whichever path made it, is logged NAMING THE PATH — so a duplicate is separable from a retried write without transcript archaeology (⟨q-524305dd⟩). */
108
+ export const pushLogLine = (path: "send-time" | "tail", id: unknown, target: string | undefined, outcome: string, error?: string) =>
109
+ `[herdr-${path === "tail" ? "tail" : "delivery"}] push id=${String(id ?? "?")} target=${target ?? "?"} outcome=${outcome}` +
110
+ (error ? ` reason=${JSON.stringify(error)}` : "") +
111
+ ` path=${path}`;
105
112
 
106
113
  /** Deliver one just-appended message to every herdr-attached recipient among `recipients`. */
107
- export async function deliverToHerdrSeats(msg: Message, recipients: string[], where: { kind: "dm" } | { kind: "room"; chan: string }): Promise<HerdrDeliveryOutcome[]> {
114
+ export async function deliverToHerdrSeats(msg: Message, recipients: string[], where: { kind: "dm" } | { kind: "room"; chan: string }, opts: { log?: (line: string) => void } = {}): Promise<HerdrDeliveryOutcome[]> {
115
+ const log = opts.log ?? ((l: string) => console.error(l));
108
116
  const t = activeTransport();
109
117
  if (!t || t.kind !== HERDR) return [];
110
118
  let markers: Map<string, TransportMarker>;
@@ -122,34 +130,41 @@ export async function deliverToHerdrSeats(msg: Message, recipients: string[], wh
122
130
  // The coordinator's gate named the old call as "still advances the cursor to EOF"; measured,
123
131
  // it did exactly that. So: find THIS message after the persisted cursor; if any earlier
124
132
  // message to someone else precedes it, defer to the recipient's tail, which delivers in order.
125
- const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
126
- const c = await readPushCursorFor(agentId);
127
- const from = where.kind === "dm" ? Number(c.inboxOffset ?? 0) : Number(((c.roomOffsets as Record<string, number> | undefined) ?? {})[where.chan] ?? 0);
128
- const after = newMessagesIn(file, from);
129
- const at = after.findIndex((m) => m.msg.id === msg.id);
130
- const owed = at === -1 ? [] : after.slice(0, at).filter((m) => m.msg.from !== agentId);
131
- if (at === -1 || owed.length > 0) {
132
- out.push({
133
- agentId,
134
- delivered: false,
135
- cursorAdvanced: false,
136
- deferred: at === -1
137
- ? "this message is not after the push cursor (already delivered, or the file moved) — left to the recipient's tail"
138
- : `${owed.length} earlier message(s) to this seat are still owed — left to the recipient's own tail, which delivers in order`,
139
- });
140
- continue;
141
- }
142
- const rendered = await renderForPane(msg, agentId, where);
143
- // A control never comes through here: send_command hands a herdr seat's control to the
144
- // transport's sendControl directly (a raw-vs-rendered branch here was dead and its
145
- // mutation survived, so it is gone). Everything delivered here is a rendered message.
146
- let r: { delivered: boolean; error?: string; enters?: number; verified?: boolean };
147
- try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, error: (e as Error).message }; }
148
- // ⟨q-15d763dc⟩ Only a VERIFIED delivery moves the cursor; anything less leaves the message owed.
149
- const verified = r.delivered && r.verified === true;
150
- // Exactly past THIS message's line — never to the end of the file, which may already hold a later one.
151
- const cursorAdvanced = verified ? await advancePushCursor(agentId, where, after[at]!.end) : false;
152
- out.push({ agentId, delivered: verified, enters: r.enters, error: r.error ?? (r.delivered ? "delivered but not verified — cursor held (q-15d763dc)" : undefined), cursorAdvanced });
133
+ // ⛔ ⟨q-524305dd⟩ read → type → verify → advance as the ONLY deliverer to this seat's source, across
134
+ // processes: the seat's own tail takes the same lock, so it cannot read this cursor mid-verify and
135
+ // type the message a second time. WAIT, never skip: the seat may have no tail at all (⟨q-cdb5b007⟩).
136
+ const locked = await withDeliveryLock(agentId, sourceKey(where), async () => {
137
+ const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
138
+ const c = await readPushCursorFor(agentId);
139
+ const from = where.kind === "dm" ? Number(c.inboxOffset ?? 0) : Number(((c.roomOffsets as Record<string, number> | undefined) ?? {})[where.chan] ?? 0);
140
+ const after = newMessagesIn(file, from);
141
+ const at = after.findIndex((m) => m.msg.id === msg.id);
142
+ const owed = at === -1 ? [] : after.slice(0, at).filter((m) => m.msg.from !== agentId);
143
+ if (at === -1 || owed.length > 0) {
144
+ out.push({
145
+ agentId,
146
+ delivered: false,
147
+ cursorAdvanced: false,
148
+ deferred: at === -1
149
+ ? "this message is not after the push cursor (already delivered, or the file moved) — left to the recipient's tail"
150
+ : `${owed.length} earlier message(s) to this seat are still owed — left to the recipient's own tail, which delivers in order`,
151
+ });
152
+ return;
153
+ }
154
+ const rendered = await renderForPane(msg, agentId, where);
155
+ // A control never comes through here: send_command hands a herdr seat's control to the
156
+ // transport's sendControl directly (a raw-vs-rendered branch here was dead and its
157
+ // mutation survived, so it is gone). Everything delivered here is a rendered message.
158
+ let r: { delivered: boolean; error?: string; enters?: number; verified?: boolean };
159
+ try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, error: (e as Error).message }; }
160
+ // ⟨q-15d763dc⟩ Only a VERIFIED delivery moves the cursor; anything less leaves the message owed.
161
+ const verified = r.delivered && r.verified === true;
162
+ log(pushLogLine("send-time", msg.id, targetOf(marker), verified ? "delivered" : r.delivered ? "typed-unconfirmed" : "not-delivered", r.error));
163
+ // Exactly past THIS message's line — never to the end of the file, which may already hold a later one.
164
+ const cursorAdvanced = verified ? await advancePushCursor(agentId, where, after[at]!.end) : false;
165
+ out.push({ agentId, delivered: verified, enters: r.enters, error: r.error ?? (r.delivered ? "delivered but not verified — cursor held (q-15d763dc)" : undefined), cursorAdvanced });
166
+ }, { wait: true, log });
167
+ if (!locked.ran) out.push({ agentId, delivered: false, cursorAdvanced: false, deferred: `${locked.why} — left to that deliverer, which types in order` });
153
168
  }
154
169
  return out;
155
170
  }
@@ -38,10 +38,10 @@
38
38
  * `REFRESH_TICKS` ticks, and no herdr subprocess runs until there is something to type.
39
39
  */
40
40
  import { readFileSync, statSync } from "node:fs";
41
- import { inboxFile, roomFile, getRooms, transportFile } from "../store.js";
41
+ import { inboxFile, roomFile, getRooms, transportFile, withDeliveryLock } from "../store.js";
42
42
  import { activeTransport, HERDR, targetOf, type Transport, type TransportMarker } from "../transports/index.js";
43
- import { renderForPane, advancePushCursor, readPushCursorFor, type Message } from "./herdr-delivery.js";
44
- import { newMessagesIn } from "./jsonl-offsets.js";
43
+ import { renderForPane, advancePushCursor, readPushCursorFor, pushLogLine, sourceKey, type Message } from "./herdr-delivery.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,18 @@ 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
- const keyOf = (s: { kind: string; chan?: string }) => (s.kind === "dm" ? "dm" : `room:${s.chan}`);
87
+ /** ONE spelling with the send-time path: the delivery lock is taken on this key by both (⟨q-524305dd⟩). */
88
+ const keyOf = (s: { kind: string; chan?: string }) => sourceKey(s.kind === "dm" ? { kind: "dm" } : { kind: "room", chan: s.chan! });
89
+ /** Set the in-process mark just past `msg`, remembering which message it follows. */
90
+ const mark = (ctx: TailContext, key: string, end: number, msg: Record<string, unknown>) => {
91
+ ctx.hw.set(key, end);
92
+ ctx.hwId.set(key, msg.id === undefined || msg.id === null ? null : String(msg.id));
93
+ };
80
94
  const sizeOf = (file: string): number => {
81
95
  try { return statSync(file).size; } catch { return 0; }
82
96
  };
@@ -169,58 +183,100 @@ export async function tailOnce(
169
183
  if (ctx.seedEof && !ctx.seeded.has(key)) {
170
184
  ctx.seeded.add(key);
171
185
  ctx.hw.set(key, size);
186
+ ctx.hwId.set(key, idEndingAt(src.file, size));
172
187
  log(`[herdr-tail] seeded ${key} at EOF (${size} bytes) for ${agentId} — first sight this process, nothing predating the tail will be typed`);
173
188
  continue;
174
189
  }
175
- const from = await offsetOf(agentId, src, ctx);
176
- // ⭐ NEGATIVE CONTROL FOR THE CLASS THIS ROW IS ABOUT: nothing is logged here when
177
- // `size === from` — a source with nothing owed must stay silent, or every idle room on
178
- // every tick becomes a line and the log that is supposed to make a real stall visible
179
- // drowns in ones that are not.
180
- if (size <= from) continue;
181
-
182
- for (const { msg, end } of newMessagesIn(src.file, from)) {
183
- if (msg.from === agentId) { ctx.hw.set(key, end); continue; }
184
- const where = src.kind === "dm" ? ({ kind: "dm" } as const) : ({ kind: "room", chan: src.chan! } as const);
185
- const rendered = await renderForPane(msg as unknown as Message, agentId, where);
186
- let r: { delivered: boolean; error?: string; verified?: boolean; safeToRetry?: boolean; outcome?: string };
187
- try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, safeToRetry: false, error: (e as Error).message }; }
188
- // ⟨q-1ce5bc97⟩ step 1(b) — EVERY push outcome, logged, before any branch decides what to do
189
- // with it. Not just the ones that end up `held`: a `delivered` needs a line too, or the
190
- // log can never distinguish "nothing was owed" from "something was pushed and it worked".
191
- log(
192
- `[herdr-tail] push id=${String(msg.id ?? "?")} target=${targetOf(marker) ?? "?"} outcome=${r.outcome ?? (r.delivered ? "delivered" : "unknown")}` +
193
- (r.error ? ` reason=${JSON.stringify(r.error)}` : ""),
194
- );
195
- if (!r.delivered && r.safeToRetry === true) {
196
- // HELD: the transport sent NO key (no ready input box, a draft, a dialog, an unreadable pane).
197
- // Nothing moves and the message is retried next tick, which costs only a screen read. Stop
198
- // this source so no later message is typed past one that did not land.
199
- out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: r.error ?? "held — nothing was typed" });
200
- ctx.sizes.delete(key); // force a re-read next tick
201
- break;
190
+ // ⛔ A BYTE MARK IS ONLY VALID WHILE THE SAME MESSAGE STILL ENDS THERE (⟨q-f44f17a7⟩). Any sender's
191
+ // server compacts a room on send, and every byte after the cut then means something else. A mark
192
+ // left PAST the new EOF made the seat deaf until the room regrew past it (the 40-minute blind
193
+ // stretch of 2026-09-24); a mark left INSIDE the new file resumes mid-stream and skips whatever
194
+ // now sits below it. So the mark is checked against the message it was set after: moved to where
195
+ // that message now ends, or — if it was archived — dropped, and the PERSISTED cursor, which the
196
+ // compactor rebased exactly (adjustCursors → rebaseBytes), is the position again.
197
+ // ⛔ ⟨q-524305dd⟩ THE SAME LOCK THE SENDER'S SEND-TIME PUSH TAKES. Without it, this tick read the push
198
+ // cursor while a sender's server was mid-verify on the same message and typed it a second time. A
199
+ // tick never waits for it: whoever holds it is delivering, and the next tick re-reads the cursor.
200
+ const locked = await withDeliveryLock(agentId, key, async () => {
201
+ const held = ctx.hw.get(key);
202
+ if (held !== undefined && idEndingAt(src.file, held) !== (ctx.hwId.get(key) ?? null)) {
203
+ const id = ctx.hwId.get(key);
204
+ const moved = id ? endOfId(src.file, id) : null;
205
+ 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"}`);
206
+ if (moved !== null) ctx.hw.set(key, moved);
207
+ else { ctx.hw.delete(key); ctx.hwId.delete(key); }
202
208
  }
203
- // ⛔ ANYTHING SHORT OF A VERIFIED DELIVERY DOES NOT MOVE THE PERSISTENT CURSOR (⟨q-15d763dc⟩).
204
- // Keys may have reached the pane (text typed, an Enter pressed) without the screen proving the
205
- // message was submitted, so it stays owed on disk: read_messages still serves it and a restart
206
- // re-offers it. The in-process high-water mark alone stops it being re-typed every tick, since
207
- // re-typing a message that may already be sitting in the box is the paste loop in another coat.
208
- // A throw lands here too: nothing says whether a key went out before it.
209
- if (!r.delivered || r.verified !== true) {
210
- ctx.hw.set(key, end);
211
- 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
- break;
213
- }
214
- // It landed and was verified. Record that in-process FIRST, so a cursor write that fails
215
- // cannot turn one delivery into a paste loop, then persist exactly past this line.
216
- ctx.hw.set(key, end);
217
- const persisted = await advancePushCursor(agentId, where, end);
218
- out.delivered.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? "") });
219
- if (!persisted) {
220
- out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: "delivered, but the push cursor could not be written — held in memory so it is not re-typed; a restart before the next successful write may type it once more" });
221
- break;
209
+ const from = await offsetOf(agentId, src, ctx);
210
+ // …and if the PERSISTED cursor is past EOF too, the compactor has not rebased it yet (it rewrites
211
+ // the file first, then the cursors) or a server without this fix compacted. `size <= from` below
212
+ // skips it either way; this only makes the state VISIBLE, once per entry — on 2026-09-24 the only
213
+ // measurements of this defect came from a seat reading the room file by hand.
214
+ if (from > size) {
215
+ 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)`);
216
+ ctx.pastEof.add(key);
217
+ } else ctx.pastEof.delete(key);
218
+ // ⭐ NEGATIVE CONTROL FOR THE CLASS THIS ROW IS ABOUT: nothing is logged here when
219
+ // `size === from` — a source with nothing owed must stay silent, or every idle room on
220
+ // every tick becomes a line and the log that is supposed to make a real stall visible
221
+ // drowns in ones that are not.
222
+ if (size <= from) return;
223
+
224
+ for (const { msg, end } of newMessagesIn(src.file, from)) {
225
+ if (msg.from === agentId) { mark(ctx, key, end, msg); continue; }
226
+ const where = src.kind === "dm" ? ({ kind: "dm" } as const) : ({ kind: "room", chan: src.chan! } as const);
227
+ const rendered = await renderForPane(msg as unknown as Message, agentId, where);
228
+ let r: { delivered: boolean; error?: string; verified?: boolean; safeToRetry?: boolean; outcome?: string };
229
+ try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, safeToRetry: false, error: (e as Error).message }; }
230
+ // ⟨q-1ce5bc97⟩ step 1(b) — EVERY push outcome, logged, before any branch decides what to do
231
+ // with it. Not just the ones that end up `held`: a `delivered` needs a line too, or the
232
+ // log can never distinguish "nothing was owed" from "something was pushed and it worked".
233
+ log(pushLogLine("tail", msg.id, targetOf(marker), r.outcome ?? (r.delivered ? "delivered" : "unknown"), r.error));
234
+ // ⛔⛆ A `pending` IS RE-PUSHED, AND THAT IS THE CURE — NOT AN EXCLUSION. `pending` means the payload
235
+ // was typed and Enter pressed and it did NOT submit, so the composer now holds that text.
236
+ // ⚠ THE FIRST FIX HERE WAS TO SKIP THE RE-PUSH, and it was wrong: it stops the retype loop but the
237
+ // dirty composer then HOLDS every later message to that pane while the seat reads QUIET instead of
238
+ // WEDGED. Curing the loop is not curing the symptom.
239
+ // ⭐ What re-pushing now does depends on which transport this server loaded, and BOTH are correct:
240
+ // • NEW (this build): the transport recognises a payload IT left un-submitted and PRESSES ENTER
241
+ // rather than retyping — the message goes out on the next tick with no human involved.
242
+ // • OLD (0.26.36, what the fleet is still running): the push refuses on the draft, which is
243
+ // today's behaviour exactly, so this is no regression while the servers wait on a restart.
244
+ // ⛔ Either way it lands in `out.held` BELOW, so the state is VISIBLE every tick with the pane named
245
+ // and the human action spelled out. A silent park is the one outcome ruled out.
246
+ // ⛔ AND THE ENTER CANNOT LIVE HERE: a consumer cannot press a key, only ask the transport to push.
247
+ // Restarting the servers that would load it is David's call, not this slice's ⟨q-1ce5bc97⟩.
248
+ if (!r.delivered && r.safeToRetry === true) {
249
+ // NO VERIFIED DELIVERY and the transport says a retry could work: either nothing was typed (no
250
+ // ready input box, a foreign draft, a dialog, an unreadable pane) or the payload is sitting
251
+ // un-submitted in the box. Stop this source either way, so no later message is typed past one
252
+ // that did not land, and re-read next tick.
253
+ out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: r.error ?? (r.outcome === "pending" ? "pending — typed but not submitted" : "held — nothing was typed") });
254
+ ctx.sizes.delete(key); // force a re-read next tick
255
+ break;
256
+ }
257
+ // ⛔ ANYTHING SHORT OF A VERIFIED DELIVERY DOES NOT MOVE THE PERSISTENT CURSOR (⟨q-15d763dc⟩).
258
+ // Keys may have reached the pane (text typed, an Enter pressed) without the screen proving the
259
+ // message was submitted, so it stays owed on disk: read_messages still serves it and a restart
260
+ // re-offers it. The in-process high-water mark alone stops it being re-typed every tick, since
261
+ // re-typing a message that may already be sitting in the box is the paste loop in another coat.
262
+ // A throw lands here too: nothing says whether a key went out before it.
263
+ if (!r.delivered || r.verified !== true) {
264
+ mark(ctx, key, end, msg);
265
+ 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)` });
266
+ break;
267
+ }
268
+ // It landed and was verified. Record that in-process FIRST, so a cursor write that fails
269
+ // cannot turn one delivery into a paste loop, then persist exactly past this line.
270
+ mark(ctx, key, end, msg);
271
+ const persisted = await advancePushCursor(agentId, where, end);
272
+ out.delivered.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? "") });
273
+ if (!persisted) {
274
+ out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: "delivered, but the push cursor could not be written — held in memory so it is not re-typed; a restart before the next successful write may type it once more" });
275
+ break;
276
+ }
222
277
  }
223
- }
278
+ }, { wait: false, log });
279
+ if (!locked.ran) ctx.sizes.delete(key); // another deliverer holds this source: re-read on the next tick
224
280
  }
225
281
  return out;
226
282
  }
@@ -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 ----------
@@ -660,9 +660,61 @@ export async function liveClaimEvidence(agentId: string, now: number): Promise<C
660
660
  // for the fleet that runs on herdr: every seat was refused by its own leftover marker (6 of 6 on
661
661
  // 2026-09-23). The pane comes from THIS process's environment, never from the caller's argument.
662
662
  const ownPane = ownPaneTarget(marker.transport);
663
- if (targetOf(marker) && ownPane && targetOf(marker) === ownPane) {
664
- samePane = true;
665
- reasons.push(`the marker names THIS process's own pane (${ownPane})`);
663
+ const markerPane = targetOf(marker);
664
+ if (markerPane && ownPane) {
665
+ // ⟨q-f995c3c7⟩ NEXT LAYER — COMPARE PANE IDENTITIES, NOT SPELLINGS. `===` on the two strings was
666
+ // the whole remaining defect: one pane has more than one name, and this seat's env said
667
+ // `wA5:p1` while its own marker said `wA3:p4` for the SAME pane. Every herdr seat had to be
668
+ // hand-staged to its env spelling before a restart, and the standdown manifest grew an
669
+ // `attachWith` field to carry the workaround. herdr already answers the canonical id for
670
+ // either name, so the fix is to ask it.
671
+ const resolver = activeTransport();
672
+ const canAsk = marker.transport === HERDR && resolver?.kind === HERDR && resolver instanceof HerdrTransport;
673
+ const mine = canAsk ? resolver.resolvePaneId(ownPane) : null;
674
+ const theirs = canAsk ? resolver.resolvePaneId(markerPane) : null;
675
+
676
+ if (mine && theirs) {
677
+ // Both spellings resolved: identity is the answer, and a spelling mismatch is no longer one.
678
+ if (mine === theirs) {
679
+ samePane = true;
680
+ reasons.push(
681
+ markerPane === ownPane
682
+ ? `the marker names THIS process's own pane (${ownPane})`
683
+ : `the marker names THIS process's own pane — '${markerPane}' and '${ownPane}' both resolve to ${mine}`,
684
+ );
685
+ }
686
+ } else if (markerPane === ownPane) {
687
+ // ⛔ THE FALLBACK, AND IT IS DELIBERATELY THE NARROW ONE. When herdr cannot be asked — a tmux
688
+ // seat, no herdr binary, a pane that no longer answers — the only thing still knowable is
689
+ // whether the two spellings are literally equal, which is exactly the pre-fix behaviour. It
690
+ // is kept so this change cannot REMOVE a reclaim that used to work.
691
+ //
692
+ // ⛔⛆ AND IT NEVER WIDENS: "could not ask" must not silently become "matches". Unequal
693
+ // spellings that could not be resolved stay UNMATCHED — the seat is refused and has to be
694
+ // looked at, which is the outcome this row exists to make rare, not the outcome it exists to
695
+ // hide. A fallback that guessed `true` here would convert a diagnosable refusal into a seat
696
+ // silently claiming a pane that may belong to someone else.
697
+ samePane = true;
698
+ reasons.push(
699
+ canAsk
700
+ ? `the marker names THIS process's own pane (${ownPane}) — spellings are equal; herdr could not resolve either to a pane id, so identity was NOT confirmed`
701
+ : `the marker names THIS process's own pane (${ownPane})`,
702
+ );
703
+ } else {
704
+ // ⛔ STATED RATHER THAN SILENT, AND IT DISTINGUISHES THE TWO WAYS OF NOT KNOWING. A refusal
705
+ // whose reason is "I could not ask" is diagnosable in one read; one that prints nothing is the
706
+ // 26-hour kind. The first version of this branch was `else if (canAsk)`, which left the
707
+ // no-resolver case saying NOTHING — the same invisible-fallback shape the row is about, one
708
+ // level down, and the tests caught it.
709
+ const why = !canAsk
710
+ ? marker.transport === HERDR
711
+ ? "no herdr transport is wired in this process (initTransportFromConfig has not run), so no pane id could be asked for"
712
+ : `the ${marker.transport} transport has no pane-identity resolver, so only the spellings could be compared`
713
+ : `herdr could not resolve ${mine ? `'${markerPane}'` : theirs ? `'${ownPane}'` : "either spelling"} to a pane id`;
714
+ reasons.push(
715
+ `the marker names pane '${markerPane}' and this process is in '${ownPane}'; ${why}, so they are NOT treated as the same pane`,
716
+ );
717
+ }
666
718
  }
667
719
  }
668
720