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.
- package/dist/auth-faults.js +145 -0
- package/dist/auth-faults.js.map +1 -0
- package/dist/capabilities.js +98 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/launch-unit.js +282 -0
- package/dist/launch-unit.js.map +1 -0
- package/dist/server.js +46 -7
- package/dist/server.js.map +1 -1
- package/dist/store.js +214 -27
- package/dist/store.js.map +1 -1
- package/dist/test-count-key.js +22 -0
- package/dist/test-count-key.js.map +1 -0
- package/dist/tools/admin.js +28 -31
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/events.js +77 -3
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/herdr-delivery.js +51 -36
- package/dist/tools/herdr-delivery.js.map +1 -1
- package/dist/tools/herdr-tail.js +115 -57
- 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/dist/tools/registry.js +50 -3
- package/dist/tools/registry.js.map +1 -1
- package/dist/transports/herdr.js +92 -2
- package/dist/transports/herdr.js.map +1 -1
- package/package.json +1 -1
- package/scripts/check-test-count.mjs +80 -6
- package/scripts/coord-attention-clock.mjs +19 -21
- package/scripts/coord-stall-clock.mjs +23 -24
- package/scripts/lib/clock-unit-cli.mjs +134 -0
- package/src/auth-faults.ts +166 -0
- package/src/capabilities.ts +100 -1
- package/src/launch-unit.ts +350 -0
- package/src/server.ts +47 -6
- package/src/store.ts +232 -25
- package/src/test-count-key.ts +21 -0
- package/src/tools/admin.ts +46 -33
- package/src/tools/events.ts +89 -4
- package/src/tools/herdr-delivery.ts +46 -31
- package/src/tools/herdr-tail.ts +108 -52
- package/src/tools/jsonl-offsets.ts +23 -0
- package/src/tools/messaging.ts +2 -2
- package/src/tools/registry.ts +55 -3
- package/src/transports/herdr.ts +93 -2
package/src/tools/events.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
}
|
package/src/tools/herdr-tail.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
if
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
ctx.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
+
}
|
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 ----------
|
package/src/tools/registry.ts
CHANGED
|
@@ -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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
|