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/server.ts
CHANGED
|
@@ -15,10 +15,12 @@ import { rotateSchema, rotateTool, rotateReconcileSchema, rotateReconcileTool }
|
|
|
15
15
|
import { subscribeSchema, subscribeTool, unsubscribeSchema, unsubscribeTool, listSubscriptionsSchema, listSubscriptionsTool } from "./tools/events.js";
|
|
16
16
|
import { scanRecordEventsSchema, scanRecordEventsTool } from "./tools/record-events.js";
|
|
17
17
|
import { capabilitiesSchema, capabilitiesTool } from "./capabilities.js";
|
|
18
|
+
import { type AuthFault, logText, responseText, detailInResponse } from "./auth-faults.js";
|
|
18
19
|
import {
|
|
19
20
|
ensureDirs,
|
|
20
21
|
getTokenMap,
|
|
21
22
|
agentForBearer,
|
|
23
|
+
retiredBearerFor,
|
|
22
24
|
tokenMapCensusLine,
|
|
23
25
|
TOKENS_FILE,
|
|
24
26
|
reloadTokenMapSync,
|
|
@@ -767,7 +769,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
767
769
|
|
|
768
770
|
addTool(
|
|
769
771
|
"subscribe",
|
|
770
|
-
"Register for a record event: `task` (a phase checkbox newly ticked), `phase` (the last open box in a phase ticked), `item` (a queue item closed), or `pr` (a PR recorded in DONE.md). Events are DERIVED from the record and refused if the ref is not in it, so the stream can never claim something the authoritative markdown does not \u2014 but the TRIGGER is the record's COMMITTED CHANGE (`scan_record_events`), not any one verb, so a hand-edited DONE.md fires exactly like `land` does. Every offered kind has an emitter: the wire enum is generated from the emitter registry, so an unsatisfiable subscription cannot be registered. Re-subscribing returns the existing registration rather than a duplicate.",
|
|
772
|
+
"Register for a record event: `task` (a phase checkbox newly ticked), `phase` (the last open box in a phase ticked), `item` (a queue item closed), or `pr` (a PR recorded in DONE.md). Events are DERIVED from the record and refused if the ref is not in it, so the stream can never claim something the authoritative markdown does not \u2014 but the TRIGGER is the record's COMMITTED CHANGE (`scan_record_events`), not any one verb, so a hand-edited DONE.md fires exactly like `land` does. Every offered kind has an emitter: the wire enum is generated from the emitter registry, so an unsatisfiable subscription cannot be registered. Re-subscribing returns the existing registration rather than a duplicate. SCOPE picks how widely you are asking and is an explicit field rather than a magic target: `exact` (the default, and what every subscription stored before this field meant) matches one target; `kind` matches every event of the kind and REFUSES a target, because a target that was accepted and ignored would read narrower than it behaves; `prefix` matches targets starting with yours — which is how you watch a class whose ids do not exist yet, e.g. `davidbalzan/groundwork-kit#` for one repo's PRs. A prefix is over whatever `target` holds FOR THAT KIND (`pr` the full ref, `item` the queue id, `task` the KEY rather than the ticked line, `phase` the phase), and an EMPTY prefix is refused because it would match everything — that is the wildcard this field exists to avoid spelling in-band; ask for `kind` and say so.",
|
|
771
773
|
subscribeSchema,
|
|
772
774
|
gate("agentId", subscribeTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
773
775
|
);
|
|
@@ -854,6 +856,11 @@ function loadTokenMap(initial: boolean): void {
|
|
|
854
856
|
}
|
|
855
857
|
}
|
|
856
858
|
|
|
859
|
+
/** The coord dir, for the bind-time log line that tells an operator where refusals are recorded. */
|
|
860
|
+
function coordRootForLog(): string {
|
|
861
|
+
return process.env.AGENT_COORD_DIR ?? `${process.env.HOME ?? "~"}/agent-coord`;
|
|
862
|
+
}
|
|
863
|
+
|
|
857
864
|
async function main() {
|
|
858
865
|
ensureDirs();
|
|
859
866
|
loadTokenMap(true);
|
|
@@ -1117,8 +1124,12 @@ async function startHttp(port: number): Promise<void> {
|
|
|
1117
1124
|
// Reverse-lookup: extract bearer from header, map → bound agent. Returns
|
|
1118
1125
|
// undefined if no map is configured (advisory mode); throws-like return of
|
|
1119
1126
|
// null if the bearer doesn't match any known agent (caller responds 401).
|
|
1120
|
-
function resolveBoundAgent(authHeader: string | undefined): { ok: boolean; agent?: string } {
|
|
1121
|
-
|
|
1127
|
+
function resolveBoundAgent(authHeader: string | undefined): { ok: boolean; agent?: string; fault?: AuthFault } {
|
|
1128
|
+
// ⟨q-a441f1e1⟩ The refusal now carries WHICH fault it was. The caller is told only about the
|
|
1129
|
+
// SHAPE of its request; everything about a credential's validity is uniform to the caller and
|
|
1130
|
+
// detailed in the daemon log — see src/auth-faults.ts for why that split is where it is.
|
|
1131
|
+
if (!authHeader) return { ok: false, fault: { kind: "no-header" } };
|
|
1132
|
+
if (!authHeader.startsWith("Bearer ")) return { ok: false, fault: { kind: "not-bearer" } };
|
|
1122
1133
|
const bearer = authHeader.slice("Bearer ".length);
|
|
1123
1134
|
// ⟨q-54adf191⟩ Pick up a tokens.json that changed while this daemon ran. Rate-limited, so the
|
|
1124
1135
|
// steady state is one stat; the file is read only when its stamp moved. Every outcome that is
|
|
@@ -1143,10 +1154,16 @@ async function startHttp(port: number): Promise<void> {
|
|
|
1143
1154
|
// actually connects gets a tail, so a 20-agent map does not spawn 20 timers for seats that
|
|
1144
1155
|
// may not exist. Idempotent — steady state is one Map lookup per request.
|
|
1145
1156
|
if (agent) startTailOnBind(agent);
|
|
1146
|
-
|
|
1157
|
+
if (agent) return { ok: true, agent };
|
|
1158
|
+
if (bearer === "") return { ok: false, fault: { kind: "empty-bearer" } };
|
|
1159
|
+
// Retired is asked only AFTER the forced re-read above: a bearer minted seconds ago must not
|
|
1160
|
+
// be reported as retired because this process had not read the file yet.
|
|
1161
|
+
const retired = retiredBearerFor(bearer);
|
|
1162
|
+
return { ok: false, fault: retired ? { kind: "retired-bearer", retired } : { kind: "unknown-bearer" } };
|
|
1147
1163
|
}
|
|
1148
1164
|
// Advisory mode: only check the shared bearer matches.
|
|
1149
|
-
|
|
1165
|
+
if (sharedExpected && authHeader === sharedExpected) return { ok: true };
|
|
1166
|
+
return { ok: false, fault: { kind: "advisory-mismatch" } };
|
|
1150
1167
|
}
|
|
1151
1168
|
|
|
1152
1169
|
// ⛔ ONE HANDLER, N SERVERS. Node binds a single address per `http.Server`, so a list of
|
|
@@ -1171,8 +1188,13 @@ async function startHttp(port: number): Promise<void> {
|
|
|
1171
1188
|
// observe traffic; TLS termination is the answer to that.
|
|
1172
1189
|
const resolved = resolveBoundAgent(req.headers.authorization);
|
|
1173
1190
|
if (!resolved.ok) {
|
|
1191
|
+
// ⟨q-a441f1e1⟩ The LOG is where an operator diagnosing "my seat stopped working" looks, and
|
|
1192
|
+
// it distinguishes every fault. The RESPONSE names only what the caller already knows about
|
|
1193
|
+
// its own request. Neither ever contains any part of a bearer.
|
|
1194
|
+
const fault = resolved.fault ?? { kind: "unknown-bearer" as const };
|
|
1195
|
+
console.error(`[agent-coord-mcp] ${logText(fault)}`);
|
|
1174
1196
|
res.writeHead(401, { "Content-Type": "text/plain", "WWW-Authenticate": "Bearer" });
|
|
1175
|
-
res.end(
|
|
1197
|
+
res.end(`${responseText(fault)}\n`);
|
|
1176
1198
|
return;
|
|
1177
1199
|
}
|
|
1178
1200
|
|
|
@@ -1217,6 +1239,25 @@ async function startHttp(port: number): Promise<void> {
|
|
|
1217
1239
|
{
|
|
1218
1240
|
const startupCensus = tokenMapCensusLine();
|
|
1219
1241
|
if (startupCensus) console.error(startupCensus);
|
|
1242
|
+
// ⛔ ⟨q-a441f1e1⟩ A BUS RUNNING WITH THE ORACLE ON SAYS SO, AT BIND. qa: the switch existed and
|
|
1243
|
+
// NOTHING announced it — no startup line, no warning — so a daemon could distinguish validity
|
|
1244
|
+
// faults to unauthenticated callers while neither its log nor its capabilities mentioned it.
|
|
1245
|
+
// An operator must not have to read the environment of a running process to learn this.
|
|
1246
|
+
// ⭐ ⟨q-a441f1e1⟩ WHERE THE WITHHELD REASONS GO, stated at bind so an operator has the exact
|
|
1247
|
+
// destination without it ever appearing in a 401 body. The response points at "this bus's daemon
|
|
1248
|
+
// log" by role; this line is what turns that role into a path for the person entitled to it.
|
|
1249
|
+
console.error(
|
|
1250
|
+
`[agent-coord-mcp] 401 reasons (unknown / retired / advisory-mismatch bearers) are written HERE, to this ` +
|
|
1251
|
+
`process's stderr — pid ${process.pid}, agent-coord dir ${coordRootForLog()}. A refused caller is told to ` +
|
|
1252
|
+
`look here and is told nothing else.`,
|
|
1253
|
+
);
|
|
1254
|
+
if (detailInResponse()) {
|
|
1255
|
+
console.error(
|
|
1256
|
+
`[agent-coord-mcp] ⛔ AGENT_COORD_401_DETAIL=response — this bus tells an UNAUTHENTICATED caller ` +
|
|
1257
|
+
`whether a bearer was never minted, or was genuine and retired, and when. That confirms a real ` +
|
|
1258
|
+
`credential to whoever just failed to authenticate. Unset it to keep the detail in this log only.`,
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1220
1261
|
}
|
|
1221
1262
|
await Promise.all(
|
|
1222
1263
|
bindAddrs.map(
|
package/src/store.ts
CHANGED
|
@@ -392,8 +392,30 @@ export type TokenMapRefresh = {
|
|
|
392
392
|
count: number | null;
|
|
393
393
|
/** Present only when the reload was REFUSED; the previous map still serves. */
|
|
394
394
|
refused?: string;
|
|
395
|
+
/**
|
|
396
|
+
* ⟨q-a441f1e1⟩ Bearers that were in the map before this reload and are not in it after — the only
|
|
397
|
+
* moment the daemon can ever observe a retirement, because nothing on disk records one.
|
|
398
|
+
* Hashes, never secrets. Empty unless `changed` is true.
|
|
399
|
+
*/
|
|
400
|
+
retired?: RetiredHash[];
|
|
395
401
|
};
|
|
396
402
|
|
|
403
|
+
/** A bearer hash that left the map, with the agent it belonged to. ⛔ A HASH, NEVER A BEARER. */
|
|
404
|
+
export type RetiredHash = { hash: string; agentId: string };
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* ⟨q-a441f1e1⟩ Which entries LEFT the map. Pure, and separate from the reload decision so it can be
|
|
408
|
+
* exercised on posed maps: a rotation (`coord-token add` on an existing agent) and a revoke
|
|
409
|
+
* (`coord-token revoke`) both present here as a hash that is gone, and they are told apart by
|
|
410
|
+
* whether the agent still holds some other key afterwards.
|
|
411
|
+
*/
|
|
412
|
+
export function retiredSince(before: Map<string, string> | null, after: Map<string, string> | null): RetiredHash[] {
|
|
413
|
+
if (!before || !after) return [];
|
|
414
|
+
const out: RetiredHash[] = [];
|
|
415
|
+
for (const [hash, agentId] of before) if (!after.has(hash)) out.push({ hash, agentId });
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
|
|
397
419
|
// The file identity we compare against: mtime, size AND inode.
|
|
398
420
|
//
|
|
399
421
|
// ⛔ THE INODE IS LOAD-BEARING, NOT BELT-AND-BRACES. `coord-token` rotates by writing a temp file and
|
|
@@ -463,7 +485,12 @@ export function decideTokenMapRefresh(
|
|
|
463
485
|
}
|
|
464
486
|
return {
|
|
465
487
|
next: { map: next, stamp, checkedAt: now },
|
|
466
|
-
result: {
|
|
488
|
+
result: {
|
|
489
|
+
changed: true,
|
|
490
|
+
reason: state.stamp === null ? "appeared" : "changed",
|
|
491
|
+
count: next.size,
|
|
492
|
+
retired: retiredSince(state.map, next),
|
|
493
|
+
},
|
|
467
494
|
};
|
|
468
495
|
} catch (e) {
|
|
469
496
|
// The stamp is deliberately NOT advanced: a half-written file that is fixed in place, with no
|
|
@@ -472,6 +499,58 @@ export function decideTokenMapRefresh(
|
|
|
472
499
|
}
|
|
473
500
|
}
|
|
474
501
|
|
|
502
|
+
/**
|
|
503
|
+
* ⟨q-a441f1e1⟩ — WHAT THIS PROCESS WATCHED LEAVE THE TOKEN MAP. Hash → who held it and when it went.
|
|
504
|
+
*
|
|
505
|
+
* ⛔ IN MEMORY, DELIBERATELY, AND IT FORGETS ON RESTART. Nothing on disk records a superseded token:
|
|
506
|
+
* `coord-token add` overwrites an agent's entry and `revoke` deletes it, so after either the old
|
|
507
|
+
* bearer is simply absent — byte-for-byte identical to one that never existed. The ONLY moment the
|
|
508
|
+
* difference is observable is the reload that removes it, which this process is already doing.
|
|
509
|
+
* Persisting it would mean a new file or a schema change (⟨q-4b9cdeab⟩'s `previous` is documented in
|
|
510
|
+
* a comment and written by NOTHING), and release A writes neither form of token. So:
|
|
511
|
+
* · a bearer retired while this daemon was RUNNING is reported as retired;
|
|
512
|
+
* · a bearer retired while it was DOWN reads as unknown, and that is the honest answer — this
|
|
513
|
+
* process never saw it valid. A test asserts that limit rather than only the happy path.
|
|
514
|
+
* Hashes only: a retirement record is never a place a secret appears.
|
|
515
|
+
*/
|
|
516
|
+
const RETIRED_MAX = 512;
|
|
517
|
+
const retiredBearers = new Map<string, RetiredBearerRecord>();
|
|
518
|
+
export type RetiredBearerRecord = { agentId: string; at: number; cause: "replaced" | "revoked" };
|
|
519
|
+
|
|
520
|
+
/** Record what a reload removed. `cause` is derived, not guessed: see `retirementCause`. */
|
|
521
|
+
export function noteRetired(entries: RetiredHash[], after: Map<string, string> | null, now = Date.now()): void {
|
|
522
|
+
for (const { hash, agentId } of entries) {
|
|
523
|
+
retiredBearers.set(hash, { agentId, at: now, cause: retirementCause(agentId, after) });
|
|
524
|
+
// Bounded so a long-lived daemon rotating often cannot grow this without limit. Insertion order
|
|
525
|
+
// is iteration order for a Map, so the oldest record is the one that goes.
|
|
526
|
+
if (retiredBearers.size > RETIRED_MAX) {
|
|
527
|
+
const oldest = retiredBearers.keys().next();
|
|
528
|
+
if (!oldest.done) retiredBearers.delete(oldest.value);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Replaced or revoked? The agent still holding a key after the reload was ROTATED; an agent with no
|
|
535
|
+
* key left was REVOKED. Derived from the map itself rather than from which command an operator ran,
|
|
536
|
+
* because the daemon never sees the command.
|
|
537
|
+
*/
|
|
538
|
+
export function retirementCause(agentId: string, after: Map<string, string> | null): "replaced" | "revoked" {
|
|
539
|
+
if (!after) return "revoked";
|
|
540
|
+
for (const held of after.values()) if (held === agentId) return "replaced";
|
|
541
|
+
return "revoked";
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** What this process knows about a presented bearer that is no longer live, or undefined. */
|
|
545
|
+
export function retiredBearerFor(bearer: string): RetiredBearerRecord | undefined {
|
|
546
|
+
return retiredBearers.get(bearerHash(bearer));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Test seam only: forget every retirement, so a case can pose an empty process memory. */
|
|
550
|
+
export function clearRetiredBearers(): void {
|
|
551
|
+
retiredBearers.clear();
|
|
552
|
+
}
|
|
553
|
+
|
|
475
554
|
export function refreshTokenMapIfChanged(opts: { force?: boolean; now?: number } = {}): TokenMapRefresh {
|
|
476
555
|
if (!tokenMapInitialized) getTokenMap();
|
|
477
556
|
const { next, result } = decideTokenMapRefresh(
|
|
@@ -479,6 +558,8 @@ export function refreshTokenMapIfChanged(opts: { force?: boolean; now?: number }
|
|
|
479
558
|
{ stamp: stampOf, read: readTokenMapSync },
|
|
480
559
|
opts,
|
|
481
560
|
);
|
|
561
|
+
// Before the cache moves: everything the old map held and the new one does not is retired.
|
|
562
|
+
if (result.retired?.length) noteRetired(result.retired, next.map, opts.now);
|
|
482
563
|
tokenMapCache = next.map;
|
|
483
564
|
tokenMapStamp = next.stamp;
|
|
484
565
|
tokenMapCheckedAt = next.checkedAt;
|
|
@@ -729,6 +810,70 @@ function sanitize(id: string): string {
|
|
|
729
810
|
return id.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
730
811
|
}
|
|
731
812
|
|
|
813
|
+
/* ── ⟨q-524305dd⟩ ONE DELIVERER PER (SEAT, SOURCE) AT A TIME, ACROSS PROCESSES ─────────────────────
|
|
814
|
+
Two paths type a room message into a herdr pane: the SENDER's server at send time (herdr-delivery)
|
|
815
|
+
and the RECIPIENT's own tail (herdr-tail). Each read the push cursor, typed, waited seconds to
|
|
816
|
+
verify the screen, and only then advanced it; the other read the same not-yet-advanced cursor in
|
|
817
|
+
that window and typed the message again. Measured 2026-09-24: 6 of 39 room messages reached one
|
|
818
|
+
seat's pane twice, 2.7–3.5s apart. The only guard was a closure-local `inFlight` flag — per
|
|
819
|
+
PROCESS, against a race between processes.
|
|
820
|
+
So read → type → verify → advance runs under a lock BOTH paths can see: a directory lock
|
|
821
|
+
(proper-lockfile, the same one appendJsonl uses) per seat and source.
|
|
822
|
+
⛔ A holder that DIES must not deafen the seat. The lock's mtime is refreshed while its owner's
|
|
823
|
+
event loop runs, and it is STALE after DELIVERY_LOCK_STALE_MS without a refresh, so a killed sender
|
|
824
|
+
costs a bounded stall, never a permanent one. It must outlast the slowest push: herdr's verify
|
|
825
|
+
sleeps by spinning, which blocks the refresh for the push's whole duration.
|
|
826
|
+
⛔ A compromised lock is LOGGED, never thrown: the library's default throws asynchronously, which
|
|
827
|
+
would take the bus server down with it. */
|
|
828
|
+
const envMs = (name: string, fallback: number, min: number) => { const n = Number(process.env[name]); return Number.isFinite(n) && n >= min ? Math.floor(n) : fallback; };
|
|
829
|
+
export const DELIVERY_LOCK_STALE_MS = envMs("AGENT_COORD_DELIVERY_LOCK_STALE_MS", 60_000, 2_000);
|
|
830
|
+
export const DELIVERY_LOCK_WAIT_MS = envMs("AGENT_COORD_DELIVERY_LOCK_WAIT_MS", 30_000, 0);
|
|
831
|
+
export const DELIVERY_LOCK_DIR = path.join(ROOT, "delivery-locks");
|
|
832
|
+
export const deliveryLockPath = (agentId: string, sourceKey: string) => path.join(DELIVERY_LOCK_DIR, `${sanitize(agentId)}--${sanitize(sourceKey)}`);
|
|
833
|
+
export type DeliveryLockOutcome<T> = { ran: true; value: T } | { ran: false; why: string };
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Run `fn` as the only deliverer to `agentId`'s `sourceKey` ("dm" or "room:<chan>") on this bus.
|
|
837
|
+
* `wait: false` (a tail tick) answers `ran: false` at once when another deliverer holds it — the next
|
|
838
|
+
* tick re-reads the cursor. `wait: true` (the send-time push) waits up to DELIVERY_LOCK_WAIT_MS rather
|
|
839
|
+
* than skipping, because the seat may have NO tail (⟨q-cdb5b007⟩) and a skipped send-time push would
|
|
840
|
+
* then be a silent drop. The lock is released on every path out of `fn`, including a throw.
|
|
841
|
+
*/
|
|
842
|
+
export async function withDeliveryLock<T>(
|
|
843
|
+
agentId: string,
|
|
844
|
+
sourceKey: string,
|
|
845
|
+
fn: () => Promise<T>,
|
|
846
|
+
opts: { wait: boolean; log?: (line: string) => void },
|
|
847
|
+
): Promise<DeliveryLockOutcome<T>> {
|
|
848
|
+
const target = deliveryLockPath(agentId, sourceKey);
|
|
849
|
+
// A compromise or a failed release is ALWAYS logged — to the caller's sink, or stderr when none is given.
|
|
850
|
+
// (Not an optional call: a lock event that is silently dropped is the invisible failure this lock exists to end.)
|
|
851
|
+
const log = opts.log ?? ((line: string) => console.error(line));
|
|
852
|
+
mkdirSync(DELIVERY_LOCK_DIR, { recursive: true });
|
|
853
|
+
const deadline = Date.now() + (opts.wait ? DELIVERY_LOCK_WAIT_MS : 0);
|
|
854
|
+
let release: () => Promise<void>;
|
|
855
|
+
for (;;) {
|
|
856
|
+
try {
|
|
857
|
+
release = await lockfile.lock(target, {
|
|
858
|
+
realpath: false,
|
|
859
|
+
stale: DELIVERY_LOCK_STALE_MS,
|
|
860
|
+
retries: 0,
|
|
861
|
+
onCompromised: (e: Error) => log(`[delivery-lock] ${sourceKey} for ${agentId} was COMPROMISED (${e.message}) — another deliverer may have taken it over; logged, not thrown`),
|
|
862
|
+
});
|
|
863
|
+
break;
|
|
864
|
+
} catch (e) {
|
|
865
|
+
if ((e as NodeJS.ErrnoException).code !== "ELOCKED") return { ran: false, why: `delivery lock unavailable: ${(e as Error).message}` };
|
|
866
|
+
if (Date.now() >= deadline) return { ran: false, why: `another deliverer holds ${sourceKey} for ${agentId}` };
|
|
867
|
+
await new Promise((r) => setTimeout(r, 40 + Math.floor(Math.random() * 60)));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
return { ran: true, value: await fn() };
|
|
872
|
+
} finally {
|
|
873
|
+
try { await release(); } catch (e) { log(`[delivery-lock] release of ${sourceKey} for ${agentId} failed: ${(e as Error).message}`); }
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
732
877
|
export async function rewriteJsonl<T>(
|
|
733
878
|
file: string,
|
|
734
879
|
filter: (entry: T) => boolean
|
|
@@ -767,6 +912,88 @@ export function archiveInboxFile(agentId: string): string {
|
|
|
767
912
|
return path.join(ARCHIVE_INBOX_DIR, `${sanitize(agentId)}.jsonl`);
|
|
768
913
|
}
|
|
769
914
|
|
|
915
|
+
/**
|
|
916
|
+
* What one archive pass did to a stream, in BOTH units a cursor can be measured in.
|
|
917
|
+
*
|
|
918
|
+
* ⛔ `removed` IS A LINE COUNT. `rebaseBytes` IS THE ONLY CORRECT WAY TO MOVE A BYTE OFFSET.
|
|
919
|
+
* A read cursor counts entries; a push cursor (`<agent>.push.json`) is the byte just past the
|
|
920
|
+
* last line typed into the pane. `adjustCursors` once subtracted `removed` from both — a line
|
|
921
|
+
* count from a byte offset — so every compaction left each push cursor far past the new EOF,
|
|
922
|
+
* and `advancePushCursor`'s monotonic `Math.max` meant nothing brought it back: the seat went
|
|
923
|
+
* deaf until the room organically regrew past the stale value (⟨q-f44f17a7⟩, 2026-09-24).
|
|
924
|
+
*/
|
|
925
|
+
export type ArchiveResult = { kept: number; removed: number; archived: number; rebaseBytes: (offset: number) => number };
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* The byte offset in the rewritten file that corresponds to `offset` in the original one: the
|
|
929
|
+
* bytes of every KEPT line that ended at or before it. Exact, not a subtraction — compaction keeps
|
|
930
|
+
* fresh decisions from before its boundary, so removed and kept lines interleave. An offset inside
|
|
931
|
+
* a line maps to that line's start (it is re-offered, never skipped); one past the old EOF — a
|
|
932
|
+
* cursor already corrupted by the old rule — maps to the new EOF.
|
|
933
|
+
*/
|
|
934
|
+
function byteRebase(keptOldEnd: number[], keptNewEnd: number[], oldSize: number): (offset: number) => number {
|
|
935
|
+
return (offset) => {
|
|
936
|
+
const x = Math.min(Math.max(0, offset), oldSize);
|
|
937
|
+
let lo = 0;
|
|
938
|
+
let hi = keptOldEnd.length; // first index whose old end is PAST x
|
|
939
|
+
while (lo < hi) {
|
|
940
|
+
const mid = (lo + hi) >> 1;
|
|
941
|
+
if (keptOldEnd[mid]! <= x) lo = mid + 1;
|
|
942
|
+
else hi = mid;
|
|
943
|
+
}
|
|
944
|
+
return lo === 0 ? 0 : keptNewEnd[lo - 1]!;
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* The pure half of an archive pass: which lines stay, which go, and the byte map between the old file
|
|
950
|
+
* and the rewritten one. No I/O, so the SAME rule is what `archiveJsonl` writes and what the
|
|
951
|
+
* `push-cursor-rebased-in-bytes` capability probe asks — not a copy of it.
|
|
952
|
+
*/
|
|
953
|
+
export function planArchive<T>(raw: string, filter: (entry: T) => boolean): { keep: string[]; archive: string[] } & Omit<ArchiveResult, "archived"> {
|
|
954
|
+
let kept = 0;
|
|
955
|
+
let removed = 0;
|
|
956
|
+
const keep: string[] = [];
|
|
957
|
+
const archive: string[] = [];
|
|
958
|
+
// Where each kept line ENDED in the old file and ends in the new one.
|
|
959
|
+
const keptOldEnd: number[] = [];
|
|
960
|
+
const keptNewEnd: number[] = [];
|
|
961
|
+
let oldPos = 0;
|
|
962
|
+
let newPos = 0;
|
|
963
|
+
const segments = raw.split("\n");
|
|
964
|
+
for (let i = 0; i < segments.length; i++) {
|
|
965
|
+
const line = segments[i]!;
|
|
966
|
+
oldPos += Buffer.byteLength(line, "utf8") + (i < segments.length - 1 ? 1 : 0);
|
|
967
|
+
if (!line.trim()) continue;
|
|
968
|
+
let keepIt = false;
|
|
969
|
+
try {
|
|
970
|
+
keepIt = filter(JSON.parse(line) as T);
|
|
971
|
+
} catch {
|
|
972
|
+
keepIt = false; // malformed lines are archived verbatim rather than parsed
|
|
973
|
+
}
|
|
974
|
+
if (keepIt) {
|
|
975
|
+
keep.push(line);
|
|
976
|
+
kept++;
|
|
977
|
+
newPos += Buffer.byteLength(line, "utf8") + 1;
|
|
978
|
+
keptOldEnd.push(oldPos);
|
|
979
|
+
keptNewEnd.push(newPos);
|
|
980
|
+
} else {
|
|
981
|
+
archive.push(line);
|
|
982
|
+
removed++;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return { keep, archive, kept, removed, rebaseBytes: byteRebase(keptOldEnd, keptNewEnd, oldPos) };
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Where a cursor lands after a rewrite, in ITS OWN unit: a read cursor counts entries (shifted down by
|
|
990
|
+
* the entries removed, clamped at 0), a push cursor is a byte offset (mapped exactly). The one place
|
|
991
|
+
* the unit is chosen — by the cursor's kind, never by a caller.
|
|
992
|
+
*/
|
|
993
|
+
export function moveCursorOffset(kind: "read" | "push", offset: number, rw: Pick<ArchiveResult, "removed" | "rebaseBytes">): number {
|
|
994
|
+
return kind === "push" ? rw.rebaseBytes(offset) : Math.max(0, offset - rw.removed);
|
|
995
|
+
}
|
|
996
|
+
|
|
770
997
|
// Like rewriteJsonl, but entries failing the filter are appended to
|
|
771
998
|
// archiveFile instead of discarded. The archive append happens under the
|
|
772
999
|
// source-file lock and BEFORE the rewrite, so a crash mid-operation can at
|
|
@@ -776,36 +1003,16 @@ export async function archiveJsonl<T>(
|
|
|
776
1003
|
file: string,
|
|
777
1004
|
archiveFile: string,
|
|
778
1005
|
filter: (entry: T) => boolean
|
|
779
|
-
): Promise<
|
|
780
|
-
if (!existsSync(file)) return { kept: 0, removed: 0, archived: 0 };
|
|
1006
|
+
): Promise<ArchiveResult> {
|
|
1007
|
+
if (!existsSync(file)) return { kept: 0, removed: 0, archived: 0, rebaseBytes: () => 0 };
|
|
781
1008
|
return withLock(file, async () => {
|
|
782
|
-
const
|
|
783
|
-
let kept = 0;
|
|
784
|
-
let removed = 0;
|
|
785
|
-
const keep: string[] = [];
|
|
786
|
-
const archive: string[] = [];
|
|
787
|
-
for (const line of raw.split("\n")) {
|
|
788
|
-
if (!line.trim()) continue;
|
|
789
|
-
try {
|
|
790
|
-
const entry = JSON.parse(line) as T;
|
|
791
|
-
if (filter(entry)) {
|
|
792
|
-
keep.push(line);
|
|
793
|
-
kept++;
|
|
794
|
-
} else {
|
|
795
|
-
archive.push(line);
|
|
796
|
-
removed++;
|
|
797
|
-
}
|
|
798
|
-
} catch {
|
|
799
|
-
archive.push(line);
|
|
800
|
-
removed++;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
1009
|
+
const { keep, archive, kept, removed, rebaseBytes } = planArchive(await fs.readFile(file, "utf8"), filter);
|
|
803
1010
|
if (archive.length) {
|
|
804
1011
|
await fs.mkdir(path.dirname(archiveFile), { recursive: true });
|
|
805
1012
|
await fs.appendFile(archiveFile, archive.join("\n") + "\n", "utf8");
|
|
806
1013
|
}
|
|
807
1014
|
await fs.writeFile(file, keep.length ? keep.join("\n") + "\n" : "", "utf8");
|
|
808
|
-
return { kept, removed, archived: archive.length };
|
|
1015
|
+
return { kept, removed, archived: archive.length, rebaseBytes };
|
|
809
1016
|
});
|
|
810
1017
|
}
|
|
811
1018
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ⟨q-f2cf9e0f⟩ — HOW A PASSING TEST IS ATTRIBUTED TO A FILE, in src/ so a probe can CALL it.
|
|
2
|
+
//
|
|
3
|
+
// `check-test-count` compares per-file pass counts against a committed record, and both the record's
|
|
4
|
+
// keys and the glob's selections are paths relative to the package. The attribution key has to be
|
|
5
|
+
// the SAME thing or the comparison is between two different namespaces.
|
|
6
|
+
//
|
|
7
|
+
// ⛔ IT WAS `test/${basename(file)}`, WHICH FLATTENS. Every test file sat directly in `test/`, so the
|
|
8
|
+
// flattened key and the real one were identical and the defect was invisible — until a suite moved to
|
|
9
|
+
// `test/live/`, whose passes then landed on the phantom key `test/<name>` while the real entry read
|
|
10
|
+
// zero. A per-file guard that mis-keys is worse than no guard: it reports drift that is not there and
|
|
11
|
+
// hides drift that is.
|
|
12
|
+
//
|
|
13
|
+
// Lives in src/ rather than beside the script for the reason measured in ⟨q-0c5476ec⟩: a probe in
|
|
14
|
+
// dist/ importing out of scripts/ breaks any install that ships dist without scripts.
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
/** The record/glob key for a test file: its path relative to the package, POSIX-separated. */
|
|
18
|
+
export function testCountKey(pkgRoot: string, file: string | undefined): string {
|
|
19
|
+
if (!file) return "(unattributed)";
|
|
20
|
+
return path.relative(pkgRoot, file).split(path.sep).join("/");
|
|
21
|
+
}
|
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
|