agent-coord-mcp 0.26.24 → 0.26.26
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/README.md +31 -70
- package/dist/capabilities.js +169 -4
- package/dist/capabilities.js.map +1 -1
- package/dist/gated-head.js +12 -1
- package/dist/gated-head.js.map +1 -1
- package/dist/server-spread.js +60 -53
- package/dist/server-spread.js.map +1 -1
- package/dist/server.js +53 -4
- package/dist/server.js.map +1 -1
- package/dist/tools/herdr-delivery.js +86 -26
- package/dist/tools/herdr-delivery.js.map +1 -1
- package/dist/tools/herdr-tail.js +243 -0
- package/dist/tools/herdr-tail.js.map +1 -0
- package/dist/tools/jsonl-offsets.js +37 -0
- package/dist/tools/jsonl-offsets.js.map +1 -0
- package/dist/tools/messaging.js +18 -0
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/records.js +18 -2
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +56 -21
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/transport.js +17 -148
- package/dist/tools/transport.js.map +1 -1
- package/dist/transports/config.js +22 -11
- package/dist/transports/config.js.map +1 -1
- package/dist/transports/herdr.js +134 -18
- package/dist/transports/herdr.js.map +1 -1
- package/dist/transports/index.js +4 -3
- package/dist/transports/index.js.map +1 -1
- package/dist/transports/tmux.js +5 -2
- package/dist/transports/tmux.js.map +1 -1
- package/dist/transports/types.js +18 -3
- package/dist/transports/types.js.map +1 -1
- package/hooks/control-bytes.mjs +69 -0
- package/hooks/submit.mjs +227 -9
- package/hooks/tier.mjs +7 -1
- package/package.json +4 -2
- package/scripts/check-global-mcp-fallback.mjs +85 -0
- package/scripts/coord-pusher.mjs +5 -1
- package/scripts/coord-seat.mjs +96 -0
- package/scripts/coord-token.mjs +79 -4
- package/scripts/stop-agent.sh +8 -4
- package/src/capabilities.ts +170 -5
- package/src/gated-head.ts +12 -1
- package/src/server-spread.ts +52 -4
- package/src/server.ts +49 -3
- package/src/tools/herdr-delivery.ts +92 -24
- package/src/tools/herdr-tail.ts +276 -0
- package/src/tools/jsonl-offsets.ts +29 -0
- package/src/tools/messaging.ts +20 -0
- package/src/tools/records.ts +18 -2
- package/src/tools/registry.ts +56 -20
- package/src/tools/transport.ts +17 -154
- package/src/transports/config.ts +25 -12
- package/src/transports/herdr.ts +193 -17
- package/src/transports/index.ts +4 -3
- package/src/transports/tmux.ts +5 -2
- package/src/transports/types.ts +24 -4
- package/hooks/tmux-pusher.mjs +0 -967
- package/scripts/spawn-agent.sh +0 -94
package/scripts/stop-agent.sh
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# stop-agent.sh — tear down
|
|
3
|
-
#
|
|
2
|
+
# stop-agent.sh — tear down a `coord-<id>` tmux session and its `pusher-<id>.pid`.
|
|
3
|
+
#
|
|
4
|
+
# ⟨q-ec020f6a⟩: spawn-agent.sh (the LOCAL tmux-push spawner) is gone, but this script is
|
|
5
|
+
# generic over the pid-file/session-name convention, not over which pusher wrote it — and
|
|
6
|
+
# coord-node.sh (the REMOTE onboarding path, still fully supported) uses the same
|
|
7
|
+
# convention and documents this script as its own teardown. Kept for that reason.
|
|
4
8
|
#
|
|
5
9
|
# Usage:
|
|
6
|
-
# scripts/stop-agent.sh --id
|
|
7
|
-
# scripts/stop-agent.sh --id
|
|
10
|
+
# scripts/stop-agent.sh --id worker-2
|
|
11
|
+
# scripts/stop-agent.sh --id worker-2 --dir /custom/coord/dir
|
|
8
12
|
|
|
9
13
|
set -euo pipefail
|
|
10
14
|
|
package/src/capabilities.ts
CHANGED
|
@@ -33,25 +33,39 @@
|
|
|
33
33
|
* Probes call behaviour. The version travels beside the answer as CONTEXT and
|
|
34
34
|
* is labelled as such.
|
|
35
35
|
*/
|
|
36
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
37
|
+
import { tmpdir } from "node:os";
|
|
38
|
+
import path from "node:path";
|
|
36
39
|
import { prRefsIn } from "./tools/record-events.js";
|
|
37
40
|
import { EVENT_KIND_IDS } from "./tools/event-kinds.js";
|
|
38
41
|
import { suggestRecordType, typedRecordMode } from "./typed-records.js";
|
|
39
42
|
import { LEAD_REFUSED, PARKED_CATEGORIES } from "./tools/away.js";
|
|
40
43
|
import { recordAuthorityFor } from "./roles.js";
|
|
44
|
+
// ⟨q-e5cb3538⟩ The renderer and the byte class live in hooks/, shared with both pushers.
|
|
45
|
+
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
46
|
+
import { injectLine } from "../hooks/tier.mjs";
|
|
47
|
+
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
48
|
+
import { findControlByte } from "../hooks/control-bytes.mjs";
|
|
49
|
+
// ⟨q-15d763dc⟩ The ready-box guard's profile, as this process read it from its own env at start.
|
|
50
|
+
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
51
|
+
import { readyProfile } from "../hooks/submit.mjs";
|
|
41
52
|
import { subscriptionHealth } from "./tools/events.js";
|
|
42
53
|
import {
|
|
43
54
|
configuredTransport,
|
|
44
55
|
runningTransport,
|
|
45
56
|
isTmuxKind,
|
|
46
57
|
targetOf,
|
|
58
|
+
herdrMarkerPid,
|
|
47
59
|
type TransportKind,
|
|
48
60
|
} from "./transports/index.js";
|
|
49
|
-
import { readAllTransportMarkers } from "./tools/registry.js";
|
|
61
|
+
import { readAllTransportMarkers, markerHoldsLiveProcess } from "./tools/registry.js";
|
|
50
62
|
import { queueWriteSchema } from "./tools/queue-write.js";
|
|
51
63
|
import { treeProvenance } from "./tools/tree-provenance.js";
|
|
52
64
|
import { detectSpread } from "./server-spread.js";
|
|
65
|
+
import { answeringServerIdentity } from "./tools/registry.js";
|
|
53
66
|
import { HerdrTransport, herdrKeyName } from "./transports/herdr.js";
|
|
54
67
|
import { tickVerdict } from "./tools/tick.js";
|
|
68
|
+
import { ensureHerdrTail, herdrTailState, newContext, newMessagesIn, stopHerdrTail } from "./tools/herdr-tail.js";
|
|
55
69
|
import { HERDR, TICK_READS_AS, TICK_STORED_AS, type TickState } from "./transports/types.js";
|
|
56
70
|
|
|
57
71
|
export type ProbeResult = {
|
|
@@ -302,6 +316,78 @@ const PROBES: Probe[] = [
|
|
|
302
316
|
return { present, evidence: `"⛔ Blocked — was 🚧 …" in flight: ${blocked} (0.26.21 said true) · "🔍 In Review": ${review} -> present=${present}` };
|
|
303
317
|
},
|
|
304
318
|
},
|
|
319
|
+
{
|
|
320
|
+
// ⛔ A HERDR SEAT'S OWN SERVER TAILS ITS INBOX. The first version of this probe checked
|
|
321
|
+
// `typeof startHerdrTail === "function"` — which read PRESENT on six seats while the tail ran
|
|
322
|
+
// on none, the exact lie this verb exists to refuse. It now STARTS a tail against a scripted
|
|
323
|
+
// herdr transport and asserts the process registry reports it running, is idempotent on a
|
|
324
|
+
// second bind, and reports it gone after a stop — plus the line reader's byte offsets, which
|
|
325
|
+
// are what stop a later held message being skipped. Runtime truth for THIS seat is reported
|
|
326
|
+
// separately, under `herdrTail`, because a probe answers "can this build" and not "is it".
|
|
327
|
+
id: "herdr-inbox-tail",
|
|
328
|
+
since: "0.26.25",
|
|
329
|
+
run: () => {
|
|
330
|
+
const probeId = `__probe-herdr-tail-${process.pid}`;
|
|
331
|
+
const scripted = { kind: HERDR } as unknown as Parameters<typeof ensureHerdrTail>[1] extends infer O ? O extends { transport?: infer T } ? T : never : never;
|
|
332
|
+
const dir = mkdtempSync(path.join(tmpdir(), "probe-tail-"));
|
|
333
|
+
try {
|
|
334
|
+
const first = ensureHerdrTail(probeId, { transport: scripted, pollMs: 3_600_000, tail: async () => ({ delivered: [], held: [] }) });
|
|
335
|
+
const again = ensureHerdrTail(probeId, { transport: scripted, pollMs: 3_600_000, tail: async () => ({ delivered: [], held: [] }) });
|
|
336
|
+
const seen = herdrTailState().some((s) => s.agentId === probeId && s.running);
|
|
337
|
+
const stopped = stopHerdrTail(probeId);
|
|
338
|
+
const gone = !herdrTailState().some((s) => s.agentId === probeId);
|
|
339
|
+
const f = path.join(dir, "inbox.jsonl");
|
|
340
|
+
const a = `${JSON.stringify({ id: "a" })}\n`;
|
|
341
|
+
writeFileSync(f, `${a}${JSON.stringify({ id: "b" })}\n{"id":"hal`);
|
|
342
|
+
const read = newMessagesIn(f, 0);
|
|
343
|
+
const offsets = read.map((m) => m.end).join(",") === `${Buffer.byteLength(a)},${Buffer.byteLength(a) + Buffer.byteLength(`${JSON.stringify({ id: "b" })}\n`)}`;
|
|
344
|
+
const present = first.started && !again.started && again.running && seen && stopped && gone && read.length === 2 && offsets;
|
|
345
|
+
return { present, evidence: `ensureHerdrTail started=${first.started} · second bind started=${again.started} running=${again.running} · registry running=${seen} · stop=${stopped} gone=${gone} · reader ${read.length} whole lines with exact end offsets=${offsets} -> present=${present}` };
|
|
346
|
+
} finally {
|
|
347
|
+
stopHerdrTail(probeId);
|
|
348
|
+
rmSync(dir, { recursive: true, force: true });
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
// ⛔⛔ ⟨q-cdb5b007⟩ — the HTTP daemon started NO tails at all, so any seat one message behind
|
|
354
|
+
// was permanently and silently deaf. This probe exists because the PREVIOUS probe could not
|
|
355
|
+
// see that: it starts a tail by calling `ensureHerdrTail` directly, which is exactly what
|
|
356
|
+
// production never did under token auth. A capability that only the test calls is not present.
|
|
357
|
+
//
|
|
358
|
+
// Probed here: a tail started the way `startTailOnBind` now starts it types NOTHING that
|
|
359
|
+
// predates it. That is the half a seat can check about ITSELF; whether this build's auth path
|
|
360
|
+
// reaches it is reported at runtime under `herdrTail`, which must be non-empty on a live
|
|
361
|
+
// daemon. Label and code disagree by design — `versionLabel` reads package.json on disk.
|
|
362
|
+
id: "http-prebound-starts-tail-at-eof",
|
|
363
|
+
since: "0.26.26",
|
|
364
|
+
run: () => {
|
|
365
|
+
const probeId = `__probe-tail-eof-${process.pid}`;
|
|
366
|
+
const dir = mkdtempSync(path.join(tmpdir(), "probe-tail-eof-"));
|
|
367
|
+
try {
|
|
368
|
+
const seeded = newContext(true);
|
|
369
|
+
const inherited = newContext(false);
|
|
370
|
+
const hasSeedFields = typeof seeded.seedEof === "boolean" && seeded.seeded instanceof Set;
|
|
371
|
+
// A seeded context adopts EOF on first sight of a source; an inherited one does not.
|
|
372
|
+
const scripted = { kind: HERDR } as unknown as NonNullable<Parameters<typeof ensureHerdrTail>[1]>["transport"];
|
|
373
|
+
const seededStart = ensureHerdrTail(probeId, {
|
|
374
|
+
transport: scripted,
|
|
375
|
+
pollMs: 3_600_000,
|
|
376
|
+
seedEof: true,
|
|
377
|
+
tail: async () => ({ delivered: [], held: [] }),
|
|
378
|
+
});
|
|
379
|
+
const running = herdrTailState().some((s) => s.agentId === probeId && s.running);
|
|
380
|
+
const present = hasSeedFields && seeded.seedEof === true && inherited.seedEof === false && seededStart.started && running;
|
|
381
|
+
return {
|
|
382
|
+
present,
|
|
383
|
+
evidence: `newContext(true).seedEof=${seeded.seedEof} · newContext(false).seedEof=${inherited.seedEof} · per-source seeded set=${seeded.seeded instanceof Set} · ensureHerdrTail({seedEof}) started=${seededStart.started} running=${running} -> present=${present}`,
|
|
384
|
+
};
|
|
385
|
+
} finally {
|
|
386
|
+
stopHerdrTail(probeId);
|
|
387
|
+
rmSync(dir, { recursive: true, force: true });
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
},
|
|
305
391
|
{
|
|
306
392
|
// ⟨q-1c95f7d4⟩ Phase 5.4 Task 5 — the external tick, probed by CALLING the code that
|
|
307
393
|
// decides what a reading MEANS (every probe here is synchronous, so the async read is
|
|
@@ -342,6 +428,72 @@ const PROBES: Probe[] = [
|
|
|
342
428
|
return { present, evidence: `HerdrTransport.kind=${scripted.kind} · scripted pane_not_found -> paneExists=${exists} · herdrKeyName("C-u").ok=${key.ok} -> present=${present}` };
|
|
343
429
|
},
|
|
344
430
|
},
|
|
431
|
+
{
|
|
432
|
+
// ⟨q-e5cb3538⟩ (0.26.25) — a control byte in message content never reaches a pane as a
|
|
433
|
+
// keystroke: the renderer every push path uses shows it as a visible escape, and a new send
|
|
434
|
+
// carrying one is refused at ingress. Measured on qa's payload (CR, ESC[201~, ETX) plus DEL
|
|
435
|
+
// and C1, rendered through THIS process's hooks — a seat whose server predates the fix answers
|
|
436
|
+
// false here, which is the question a release is delivered by.
|
|
437
|
+
id: "pane-render-neutralizes-controls",
|
|
438
|
+
since: "0.26.25",
|
|
439
|
+
run: () => {
|
|
440
|
+
const body = "X1\rX2\x1b[201~X3\x03X4\x7f\u0085";
|
|
441
|
+
const line = String(injectLine({ ts: 0, tag: "DM", from: "probe", text: body }));
|
|
442
|
+
const leaked = (line.match(/[\x00-\x09\x0b-\x1f\x7f\u0080-\u009f]/g) ?? []).length;
|
|
443
|
+
const visible = line.includes("X1\\rX2\\x1b[201~X3\\x03X4\\x7f\\u0085");
|
|
444
|
+
const refused = findControlByte({ text: body }) !== null && findControlByte({ text: "ok\n\tok" }) === null;
|
|
445
|
+
const present = leaked === 0 && visible && refused;
|
|
446
|
+
return { present, evidence: `rendered qa's payload + DEL + C1 -> leaked ${leaked} control byte(s) · escapes visible=${visible} · ingress refuses it and passes LF/TAB=${refused} -> present=${present}` };
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
// ⟨q-abd88dd4⟩ — a herdr marker's pid is chosen for the pre-herdr readers that cannot be
|
|
451
|
+
// patched: 1 only where this process may not signal pid 1 (EPERM), 0 otherwise, and this
|
|
452
|
+
// build never reads it as a process. Both functions are new in 0.26.25.
|
|
453
|
+
id: "herdr-marker-survives-pre-herdr-readers",
|
|
454
|
+
since: "0.26.25",
|
|
455
|
+
run: () => {
|
|
456
|
+
const eperm = () => { const e = new Error("EPERM") as NodeJS.ErrnoException; e.code = "EPERM"; throw e; };
|
|
457
|
+
const notSignalable = herdrMarkerPid(eperm).pid;
|
|
458
|
+
const signalable = herdrMarkerPid(() => true).pid;
|
|
459
|
+
const holds = markerHoldsLiveProcess({ agentId: "probe", transport: HERDR, pid: 1, since: 0 });
|
|
460
|
+
const present = notSignalable === 1 && signalable === 0 && holds === false;
|
|
461
|
+
return { present, evidence: `kill(1,0) EPERM -> pid ${notSignalable} · kill(1,0) permitted (root/container) -> pid ${signalable} · herdr marker pid 1 holds a live process: ${holds} -> present=${present}` };
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
// ⟨q-3cd5a77d⟩ — the result word counts only in the CLAIM position. A seat on an older server
|
|
466
|
+
// reads the #359 routing header (PASS in the PR title) as the coordinator gating it PASS.
|
|
467
|
+
id: "gate-claim-needs-claim-position",
|
|
468
|
+
since: "0.26.25",
|
|
469
|
+
run: () => {
|
|
470
|
+
const sha = "0826519e043add993c5d757908c1903239542160";
|
|
471
|
+
const line = (text: string) => JSON.stringify({ ts: 1, from: "probe", text, record: { type: "go", payload: {}, cites: [{ kind: "pr", ref: "#359" }] } });
|
|
472
|
+
const routing = gateClaimsIn(line(`GATE, prioritised: #359 (test-all prints PASS after FAILED) @ ${sha}`), "359", sha).length;
|
|
473
|
+
const typed = gateClaimsIn(line(`QA GATE — **PASS** @ \`${sha}\``), "359", sha).length;
|
|
474
|
+
const present = routing === 0 && typed === 1;
|
|
475
|
+
return { present, evidence: `routing header with PASS in the title -> ${routing} claim(s) · typed QA GATE — **PASS** @ sha -> ${typed} -> present=${present}` };
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
// ⟨q-18a719c5⟩ The spread can place a seat at all: the identity THIS server stamps names its
|
|
480
|
+
// module (resolved by package name, through the real caller in dist/tools/registry.js), a
|
|
481
|
+
// stamp whose pid is gone reads unknown, and unknowns block AGREED but not DIVERGED. Before
|
|
482
|
+
// 0.26.25 the module resolved to undefined on every server.
|
|
483
|
+
id: "server-spread-places-seats",
|
|
484
|
+
since: "0.26.25",
|
|
485
|
+
run: () => {
|
|
486
|
+
const me = answeringServerIdentity();
|
|
487
|
+
const installed = typeof me.serverModule === "string" ? { mtime: 1_000, module: me.serverModule } : null;
|
|
488
|
+
const seat = (agentId: string, startedAt: number, pid = 101) => ({ agentId, serverPid: pid, serverStartedAt: startedAt, serverModule: me.serverModule });
|
|
489
|
+
const alive = (pid: number) => pid !== 999;
|
|
490
|
+
const stale = installed ? detectSpread([seat("gone", 2_000, 999), seat("b", 3_000)], installed, { isRunning: alive }) : null;
|
|
491
|
+
const split = installed ? detectSpread([seat("old", 500), seat("new", 3_000), { agentId: "mute" }], installed, { isRunning: alive }) : null;
|
|
492
|
+
const staleUnknown = !!stale?.uncomparable.some((u) => u.agentId === "gone" && /no longer running/.test(u.why));
|
|
493
|
+
const present = typeof me.serverModule === "string" && staleUnknown && split?.state === "DIVERGED";
|
|
494
|
+
return { present, evidence: `module ${me.serverModule ?? "UNRESOLVED"} · dead stamp -> unknown: ${staleUnknown} · split + unstamped seat -> ${split?.state ?? "n/a"} -> present=${present}` };
|
|
495
|
+
},
|
|
496
|
+
},
|
|
345
497
|
{
|
|
346
498
|
id: "record-authority",
|
|
347
499
|
since: "0.24.0",
|
|
@@ -382,8 +534,8 @@ export type TransportCapability = {
|
|
|
382
534
|
agrees: boolean;
|
|
383
535
|
/** What was called and what came back. */
|
|
384
536
|
evidence: string;
|
|
385
|
-
/** Where `configured` came from — config file
|
|
386
|
-
configuredSource: "config" | "env"
|
|
537
|
+
/** Where `configured` came from — config file or env. ⟨q-ec020f6a⟩: no built-in default. */
|
|
538
|
+
configuredSource: "config" | "env";
|
|
387
539
|
/**
|
|
388
540
|
* MIXED FLEET, MADE LOUD (3.4). Agents whose marker names a transport other
|
|
389
541
|
* than the running one. Whole-fleet is the rule; this is the code noticing
|
|
@@ -524,7 +676,7 @@ export function probeCapabilities(context: { module: string; versionLabel: strin
|
|
|
524
676
|
|
|
525
677
|
import { resolveServerIdentity } from "./server-identity.js";
|
|
526
678
|
import { seatBuildOf, installedFrom, psReader, type SeatBuild } from "./tools/seat-build.js";
|
|
527
|
-
import { isInFlightStatus } from "./tools/stall.js";
|
|
679
|
+
import { isInFlightStatus, gateClaimsIn } from "./tools/stall.js";
|
|
528
680
|
import { parseWorkDoc, queueItemsOf, stampQueueIds } from "@davidbalzan/groundwork-seam";
|
|
529
681
|
|
|
530
682
|
export const capabilitiesSchema = {} as const;
|
|
@@ -609,11 +761,24 @@ export async function seatBuilds(): Promise<{ installed: { module: string; hookP
|
|
|
609
761
|
};
|
|
610
762
|
}
|
|
611
763
|
|
|
764
|
+
/**
|
|
765
|
+
* ⟨q-15d763dc⟩ Is THIS process's pane push guarded? `guarded:false` means AGENT_COORD_READY_PROFILE=none
|
|
766
|
+
* was set in this server's env at start: its pushes type into panes without checking for Claude
|
|
767
|
+
* Code's input box. Stated here so an unguarded seat is visible fleet-wide, not only in a log.
|
|
768
|
+
*/
|
|
769
|
+
function readyBoxState(): { profile: string; guarded: boolean; source: string; warning?: string } {
|
|
770
|
+
const p = readyProfile() as { profile: string; raw: string | null; warning?: string };
|
|
771
|
+
return { profile: p.profile, guarded: p.profile !== "none", source: "AGENT_COORD_READY_PROFILE in this process's env at start (never a message or the bus dir)", ...(p.warning ? { warning: p.warning } : {}) };
|
|
772
|
+
}
|
|
773
|
+
|
|
612
774
|
export async function capabilitiesTool() {
|
|
613
775
|
const id = resolveServerIdentity();
|
|
614
776
|
const report = probeCapabilities({ module: id.path, versionLabel: id.version });
|
|
615
777
|
try {
|
|
616
|
-
|
|
778
|
+
// ⭐ `herdrTail` is RUNTIME truth for this process — the tails actually ticking here — kept apart
|
|
779
|
+
// from the build probe on purpose, because "this build can tail" and "this seat is tailing"
|
|
780
|
+
// are different facts and the first version of #353 reported the first as the second.
|
|
781
|
+
return { ...report, transport: await probeTransport(), herdrTail: herdrTailState(), readyBox: readyBoxState(), seats: await seatBuilds() };
|
|
617
782
|
} catch (e) {
|
|
618
783
|
// A THROWN TRANSPORT PROBE IS NOT A BROKEN VERB. An unknown configured value
|
|
619
784
|
// refuses at startup by design, and this verb is exactly what an operator
|
package/src/gated-head.ts
CHANGED
|
@@ -233,8 +233,19 @@ export function gatedBy(verdicts: PassVerdict[], head: string, mergedAt: number
|
|
|
233
233
|
* so the experiment CONFIRMS the false mechanism — whoever ran it would conclude they had
|
|
234
234
|
* removed something load-bearing and got away with it. If you are removing it, the thing
|
|
235
235
|
* to check is `DECLARES`, not this.
|
|
236
|
+
*
|
|
237
|
+
* ⛔ ⟨q-3cd5a77d⟩ THE RESULT WORD MUST STAND IN THE CLAIM POSITION — right after the opener, past
|
|
238
|
+
* only separators (`:` `—` `–` `-`, spaces) and an optional `**`. The first version accepted PASS or
|
|
239
|
+
* FAIL ANYWHERE before the `@`, so a ROUTING header read as a verdict:
|
|
240
|
+
* `GATE, prioritised: #359 (⟨q-3605ede3⟩, test-all prints PASS after FAILED) @ 0826519e…`
|
|
241
|
+
* — the word was in the PR's TITLE. Bus history is permanent, so that one DM (11:19:43Z) turned
|
|
242
|
+
* `verdict-names-gater`'s live-population test red on every chain on the machine, and made
|
|
243
|
+
* `stall_check` report #359 as `reported-unscribed` with the COORDINATOR claiming PASS at its head.
|
|
244
|
+
* Measured before the change, old vs new over 126 real lines (the 61-line verdict corpus + every
|
|
245
|
+
* distinct line the old form matched on the live bus and in kit PR comments): they differ on
|
|
246
|
+
* exactly that routing line, and agree — result word and sha — on the other 125.
|
|
236
247
|
*/
|
|
237
|
-
export const VERDICT_COMMENT = /^(?:QA GATE|\*\*GATE|GATE)\b[
|
|
248
|
+
export const VERDICT_COMMENT = /^(?:QA GATE|\*\*GATE|GATE)\b[\s:—–-]*(?:\*\*\s*)?(PASS|FAIL)\b[^\n]*?@\s*`?([0-9a-f]{7,40})`?/;
|
|
238
249
|
|
|
239
250
|
/**
|
|
240
251
|
* WHAT MAKES A VERDICT BINDING, expressed as a shape instead of a phrase: a
|
package/src/server-spread.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
/*
|
|
2
4
|
* ⛔⛆⛆ TWO SEATS RAN THE SAME VERB, GOT `153` AND `138`, AND BOTH SERVERS HONESTLY
|
|
3
5
|
* REPORTED `versionLabel 0.26.20` — `⟨q-cec42e20⟩`.
|
|
@@ -95,9 +97,19 @@ const sideOf = (startedAt: number, installMtime: number): Cohort["side"] =>
|
|
|
95
97
|
* read agrees. The cohorts are still returned, so a reader sees what was established as
|
|
96
98
|
* well as what was not.
|
|
97
99
|
*/
|
|
100
|
+
/** Is a stamped server pid still running? EPERM means it exists and is not ours. */
|
|
101
|
+
export function pidRunning(pid: number): boolean {
|
|
102
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
103
|
+
try { process.kill(pid, 0); return true; } catch (e) { return (e as NodeJS.ErrnoException).code === "EPERM"; }
|
|
104
|
+
}
|
|
105
|
+
|
|
98
106
|
export function detectSpread(
|
|
99
107
|
identities: ServerIdentity[],
|
|
100
108
|
installed: InstalledBuild | null,
|
|
109
|
+
// PURE BY DEFAULT: the detector trusts a stamped pid unless the caller supplies a liveness
|
|
110
|
+
// check. The production caller (`list_agents`) passes `pidRunning`; a fixture of a measured
|
|
111
|
+
// incident, whose pids belong to processes long gone, is still placed by its stamps.
|
|
112
|
+
{ isRunning = () => true }: { isRunning?: (pid: number) => boolean } = {},
|
|
101
113
|
): SpreadVerdict {
|
|
102
114
|
const uncomparable: Uncomparable[] = [];
|
|
103
115
|
const placed: { agentId: string; side: Cohort["side"]; module?: string }[] = [];
|
|
@@ -120,6 +132,16 @@ export function detectSpread(
|
|
|
120
132
|
});
|
|
121
133
|
continue;
|
|
122
134
|
}
|
|
135
|
+
// ⟨q-18a719c5⟩ A STAMP OUTLIVES ITS SERVER. If the process that stamped this entry is gone,
|
|
136
|
+
// the module it names is what a DEAD process ran — not what serves the seat now. Unknown,
|
|
137
|
+
// naming the pid, until the seat's next server binds and stamps over it.
|
|
138
|
+
if (typeof id.serverPid === "number" && !isRunning(id.serverPid)) {
|
|
139
|
+
uncomparable.push({
|
|
140
|
+
agentId: id.agentId,
|
|
141
|
+
why: `stamped by server pid ${id.serverPid}, which is no longer running — the entry names a process that is gone, not the one serving this seat`,
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
123
145
|
if (installed === null) continue;
|
|
124
146
|
// ⭐ A DIFFERENT BUILD IS ITS OWN COHORT, never folded into `pre-install`: "which
|
|
125
147
|
// side of the install did you start" is not a question about a process that does
|
|
@@ -156,6 +178,12 @@ export function detectSpread(
|
|
|
156
178
|
uncomparable,
|
|
157
179
|
};
|
|
158
180
|
}
|
|
181
|
+
// ⟨q-18a719c5⟩ UNKNOWNS BLOCK AGREEMENT, NOT DISAGREEMENT. Two readable seats on different
|
|
182
|
+
// sides of the install is established whatever the unreadable ones run, so it is reported as
|
|
183
|
+
// DIVERGED with the unknowns listed. Only AGREED needs every seat, and that rule (#293) stands.
|
|
184
|
+
if (uncomparable.length && cohorts.length > 1) {
|
|
185
|
+
return { state: "DIVERGED", cohorts, comparable, uncomparable };
|
|
186
|
+
}
|
|
159
187
|
if (uncomparable.length) {
|
|
160
188
|
return {
|
|
161
189
|
state: "CANNOT_COMPARE",
|
|
@@ -218,15 +246,35 @@ export function reportSpread(v: SpreadVerdict, installed: InstalledBuild | null)
|
|
|
218
246
|
* question is "what build is the answering process running", and the answering process
|
|
219
247
|
* is the one executing this file.
|
|
220
248
|
*/
|
|
249
|
+
export const COORD_MCP_PACKAGE_NAME = "agent-coord-mcp";
|
|
250
|
+
|
|
221
251
|
export function installedBuild(
|
|
222
252
|
fromUrl: string,
|
|
223
253
|
statSync: (p: string) => { mtimeMs: number },
|
|
254
|
+
readFile: (p: string) => string = (p) => readFileSync(p, "utf8"),
|
|
224
255
|
): InstalledBuild | null {
|
|
256
|
+
// ⟨q-18a719c5⟩ THE ROOT IS FOUND BY THE PACKAGE'S OWN IDENTITY, not by a depth. This used to
|
|
257
|
+
// strip one level (`/dist/<file>`), and both production callers live in
|
|
258
|
+
// `dist/tools/registry.js`: the regex never matched, `statSync(".../registry.js/package.json")`
|
|
259
|
+
// threw, and this returned null on every server — `serverSpread` read CANNOT_COMPARE on every
|
|
260
|
+
// fleet and the heartbeat stamp wrote `serverModule: undefined`. A depth count, or the FIRST
|
|
261
|
+
// package.json found, breaks in other layouts (a global npm or homebrew install nests the
|
|
262
|
+
// package under node_modules, a workspace nests it under packages/); the package.json whose
|
|
263
|
+
// `name` is agent-coord-mcp is the package in every one of them. Nothing found stays null.
|
|
225
264
|
try {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
265
|
+
let dir = path.dirname(decodeURIComponent(new URL(fromUrl).pathname));
|
|
266
|
+
for (let hops = 0; hops < 12; hops++) {
|
|
267
|
+
const pkg = path.join(dir, "package.json");
|
|
268
|
+
try {
|
|
269
|
+
if (JSON.parse(readFile(pkg))?.name === COORD_MCP_PACKAGE_NAME) {
|
|
270
|
+
return { mtime: statSync(pkg).mtimeMs, module: dir };
|
|
271
|
+
}
|
|
272
|
+
} catch { /* no readable package.json here — keep walking */ }
|
|
273
|
+
const up = path.dirname(dir);
|
|
274
|
+
if (up === dir) break;
|
|
275
|
+
dir = up;
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
230
278
|
} catch {
|
|
231
279
|
return null;
|
|
232
280
|
}
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
3
|
+
import { readyProfileStartupLine } from "../hooks/submit.mjs";
|
|
2
4
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { initTransportFromConfig } from "./transports/index.js";
|
|
5
|
+
import { HERDR, initTransportFromConfig } from "./transports/index.js";
|
|
6
|
+
import { startTailOnBind, stopHerdrTail } from "./tools/herdr-tail.js";
|
|
4
7
|
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
|
5
8
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -37,6 +40,7 @@ import {
|
|
|
37
40
|
forceUnregisterTool,
|
|
38
41
|
heartbeatSchema,
|
|
39
42
|
heartbeatTool,
|
|
43
|
+
stampServerIdentity,
|
|
40
44
|
joinRoomSchema,
|
|
41
45
|
joinRoomTool,
|
|
42
46
|
joinSchema,
|
|
@@ -334,6 +338,12 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
334
338
|
const via = await guardFirstClaim(claimed, args);
|
|
335
339
|
bound = claimed;
|
|
336
340
|
recordSessionBinding(claimed, via);
|
|
341
|
+
// ⛔ THE TAIL STARTS HERE, AT THE BINDING — not from an env var no seat sets.
|
|
342
|
+
startTailOnBind(claimed);
|
|
343
|
+
// ⟨q-18a719c5⟩ A server that re-binds WITHOUT `join` (a restart that first calls
|
|
344
|
+
// attach_agent, or any gated tool) serves the seat from now on: stamp the entry if it
|
|
345
|
+
// exists. A seat not yet registered is stamped by the `register` that creates it.
|
|
346
|
+
await stampServerIdentity(claimed);
|
|
337
347
|
}
|
|
338
348
|
} else if (bound !== claimed) {
|
|
339
349
|
throw new Error(
|
|
@@ -384,6 +394,8 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
384
394
|
const via = await guardFirstClaim(claimed, args);
|
|
385
395
|
bound = claimed;
|
|
386
396
|
recordSessionBinding(claimed, via);
|
|
397
|
+
// ⛔ THE TAIL STARTS HERE, AT THE BINDING — not from an env var no seat sets.
|
|
398
|
+
startTailOnBind(claimed);
|
|
387
399
|
} else if (bound !== claimed) {
|
|
388
400
|
throw new Error(
|
|
389
401
|
`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`,
|
|
@@ -550,6 +562,8 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
550
562
|
const via = await guardFirstClaim(claimed, args);
|
|
551
563
|
bound = claimed;
|
|
552
564
|
recordSessionBinding(claimed, via);
|
|
565
|
+
// ⛔ THE TAIL STARTS HERE, AT THE BINDING — not from an env var no seat sets.
|
|
566
|
+
startTailOnBind(claimed);
|
|
553
567
|
} else if (bound !== claimed) {
|
|
554
568
|
throw new Error(`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`);
|
|
555
569
|
}
|
|
@@ -558,8 +572,14 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
558
572
|
if (result && typeof result === "object" && (result as { ok?: unknown }).ok === true) {
|
|
559
573
|
const to = (result as { to?: unknown }).to;
|
|
560
574
|
if (typeof to === "string") {
|
|
575
|
+
const from = bound;
|
|
561
576
|
bound = to;
|
|
562
577
|
recordSessionBinding(to, "rename");
|
|
578
|
+
// ⛔ THE TAIL MOVES WITH THE IDENTITY. The coordinator's gate measured a renamed herdr seat
|
|
579
|
+
// still tailing its OLD id's inbox — `herdrTail` listed only the old name — so the seat
|
|
580
|
+
// went deaf under its new one. Stop the old, start the new.
|
|
581
|
+
if (from && from !== to) stopHerdrTail(from);
|
|
582
|
+
startTailOnBind(to);
|
|
563
583
|
}
|
|
564
584
|
}
|
|
565
585
|
return jsonResult(result);
|
|
@@ -825,8 +845,24 @@ async function main() {
|
|
|
825
845
|
// branching in send_command or attach.
|
|
826
846
|
try {
|
|
827
847
|
const t = initTransportFromConfig();
|
|
828
|
-
|
|
829
|
-
|
|
848
|
+
// ⟨q-ec020f6a⟩: no built-in default any more — every resolution has a real source, always logged.
|
|
849
|
+
console.error(`[agent-coord-mcp] transport: ${t.kind} (from ${t.source})`);
|
|
850
|
+
// ⛔ A HERDR SEAT MUST TAIL ITS OWN INBOX, OR IT IS DEAF TO EVERY TMUX SENDER.
|
|
851
|
+
// The send-time path can only see the SENDER's transport (a module-level singleton), so a
|
|
852
|
+
// tmux seat's server can never type into a herdr pane. The duty belongs to the seat that
|
|
853
|
+
// needs waking — the only party guaranteed to be where it is — and this is where it starts.
|
|
854
|
+
// The env var is ONE way to bind identity, and no seat on this fleet uses it — they bind at
|
|
855
|
+
// `join`. So this branch only covers a server launched pre-bound; the tail's real start is the
|
|
856
|
+
// binding hook below, which runs at every bind. A herdr server with neither simply waits.
|
|
857
|
+
// ⟨q-15d763dc⟩ A herdr server types into panes itself, so an unguarded one says so at start.
|
|
858
|
+
if (t.kind === HERDR) {
|
|
859
|
+
const guardLine = readyProfileStartupLine("agent-coord-mcp");
|
|
860
|
+
if (guardLine) console.error(guardLine.trimEnd());
|
|
861
|
+
}
|
|
862
|
+
if (t.kind === HERDR && process.env.AGENT_COORD_BOUND_AGENT) {
|
|
863
|
+
startTailOnBind(process.env.AGENT_COORD_BOUND_AGENT);
|
|
864
|
+
} else if (t.kind === HERDR) {
|
|
865
|
+
console.error("[agent-coord-mcp] herdr transport: the inbox tail starts when this session binds its identity (join)");
|
|
830
866
|
}
|
|
831
867
|
} catch (e) {
|
|
832
868
|
// Loud, and fatal. A server that starts with an unusable transport config
|
|
@@ -1040,6 +1076,16 @@ async function startHttp(port: number): Promise<void> {
|
|
|
1040
1076
|
const tokenMap = getTokenMap();
|
|
1041
1077
|
if (tokenMap) {
|
|
1042
1078
|
const agent = tokenMap.get(bearer);
|
|
1079
|
+
// ⛔ THE ONLY PLACE A PRE-BOUND SEAT CAN GET A TAIL. The `startTailOnBind` sites in the tool
|
|
1080
|
+
// path all sit behind `bound === undefined` — the TOFU claim. Here identity arrives already
|
|
1081
|
+
// decided by the bearer, that branch is unreachable, and before this line NO tail ever
|
|
1082
|
+
// started under HTTP (⟨q-cdb5b007⟩: `herdrTail: []`, and send-time delivery deferring to a
|
|
1083
|
+
// tail that does not exist drops the message permanently and silently).
|
|
1084
|
+
//
|
|
1085
|
+
// Deliberately lazy rather than a loop over the whole token map at boot: only a seat that
|
|
1086
|
+
// actually connects gets a tail, so a 20-agent map does not spawn 20 timers for seats that
|
|
1087
|
+
// may not exist. Idempotent — steady state is one Map lookup per request.
|
|
1088
|
+
if (agent) startTailOnBind(agent);
|
|
1043
1089
|
return agent ? { ok: true, agent } : { ok: false };
|
|
1044
1090
|
}
|
|
1045
1091
|
// Advisory mode: only check the shared bearer matches.
|
|
@@ -20,13 +20,15 @@
|
|
|
20
20
|
* `[agent-coord] …` banner and the attributed line) but one at a time. Tiers still decide
|
|
21
21
|
* the banner text. Batching for herdr is Task 5 territory if it is wanted.
|
|
22
22
|
*/
|
|
23
|
-
import { statSync } from "node:fs";
|
|
23
|
+
import { mkdirSync, statSync } from "node:fs";
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import { pathToFileURL } from "node:url";
|
|
26
26
|
import { activeTransport, HERDR, type TransportMarker } from "../transports/index.js";
|
|
27
27
|
import { ROOT, inboxFile, roomFile } from "../store.js";
|
|
28
28
|
import { loadLiveTransports } from "./registry.js";
|
|
29
|
+
import { newMessagesIn } from "./jsonl-offsets.js";
|
|
29
30
|
import type { Message } from "./shared.js";
|
|
31
|
+
export type { Message };
|
|
30
32
|
|
|
31
33
|
type Tier = { formatBatch: (batch: unknown[], agentId: string, rooms: string[]) => string; classifyTier: (m: unknown, opts?: unknown) => string };
|
|
32
34
|
type PushCursor = { readPushCursor: (root: string, safeId: string) => Record<string, unknown>; writePushCursor: (root: string, safeId: string, c: Record<string, unknown>) => void };
|
|
@@ -42,7 +44,64 @@ function loadHooks(): Promise<{ tier: Tier; cursor: PushCursor }> {
|
|
|
42
44
|
}
|
|
43
45
|
const safeId = (id: string) => id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
|
|
48
|
+
export type Where = { kind: "dm" } | { kind: "room"; chan: string };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ⭐ ONE RENDERER AND ONE CURSOR RULE, SHARED BY BOTH PATHS. The send-time path
|
|
52
|
+
* (`deliverToHerdrSeats`) and the seat's own tail (`herdr-tail.ts`) must produce the same bytes
|
|
53
|
+
* in the pane and must move the same cursor the same way — a second copy of either is a second
|
|
54
|
+
* truth about what a seat has already seen, and the copy is the one that rots.
|
|
55
|
+
*/
|
|
56
|
+
export async function renderForPane(msg: Message, agentId: string, where: Where): Promise<string> {
|
|
57
|
+
const { tier } = await loadHooks();
|
|
58
|
+
const tagged = { ...msg, tag: where.kind === "dm" ? "DM" : `room #${where.chan}` };
|
|
59
|
+
return tier.formatBatch([{ ...tagged, tier: tier.classifyTier(tagged) }], agentId, where.kind === "room" ? [where.chan] : []);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Advance the PUSH cursor past everything currently in the source. Returns whether it moved.
|
|
64
|
+
* ⛔ Called ONLY after a verified delivery — that is the rule this whole module exists to keep.
|
|
65
|
+
*/
|
|
66
|
+
export async function advancePushCursor(agentId: string, where: Where, toOffset?: number): Promise<boolean> {
|
|
67
|
+
try {
|
|
68
|
+
const { cursor } = await loadHooks();
|
|
69
|
+
// ⛔ THE CURSOR DIRECTORY MUST EXIST OR THE WRITE FAILS SILENTLY, AND A CURSOR THAT NEVER
|
|
70
|
+
// ADVANCES RE-TYPES THE SAME MESSAGE ON EVERY TICK — a paste loop into a live pane. Found by
|
|
71
|
+
// the tail's own double-delivery test against a fresh bus directory, where `cursors/` had not
|
|
72
|
+
// been created yet: `writePushCursor` threw ENOENT, the `catch` below turned it into
|
|
73
|
+
// `false`, and the delivery looked fine from every other angle.
|
|
74
|
+
mkdirSync(path.join(ROOT, "cursors"), { recursive: true });
|
|
75
|
+
const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
|
|
76
|
+
// `toOffset` is the byte just past the line that was delivered. Without it (the send-time path,
|
|
77
|
+
// where the just-appended message IS the last line) the file size is the same number.
|
|
78
|
+
const size = toOffset ?? statSync(file).size;
|
|
79
|
+
const id = safeId(agentId);
|
|
80
|
+
const c = cursor.readPushCursor(ROOT, id) ?? {};
|
|
81
|
+
if (where.kind === "dm") c.inboxOffset = Math.max(Number(c.inboxOffset ?? 0), size);
|
|
82
|
+
else {
|
|
83
|
+
const offsets = ((c.roomOffsets as Record<string, number> | undefined) ?? {});
|
|
84
|
+
offsets[where.chan] = Math.max(Number(offsets[where.chan] ?? 0), size);
|
|
85
|
+
c.roomOffsets = offsets;
|
|
86
|
+
}
|
|
87
|
+
cursor.writePushCursor(ROOT, id, c);
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The push cursor as the pusher stores it, for a caller that needs to read an offset. */
|
|
95
|
+
export async function readPushCursorFor(agentId: string): Promise<Record<string, unknown>> {
|
|
96
|
+
try {
|
|
97
|
+
const { cursor } = await loadHooks();
|
|
98
|
+
return cursor.readPushCursor(ROOT, safeId(agentId)) ?? {};
|
|
99
|
+
} catch {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type HerdrDeliveryOutcome = { agentId: string; delivered: boolean; enters?: number; error?: string; cursorAdvanced: boolean; deferred?: string };
|
|
46
105
|
|
|
47
106
|
/** Deliver one just-appended message to every herdr-attached recipient among `recipients`. */
|
|
48
107
|
export async function deliverToHerdrSeats(msg: Message, recipients: string[], where: { kind: "dm" } | { kind: "room"; chan: string }): Promise<HerdrDeliveryOutcome[]> {
|
|
@@ -56,32 +115,41 @@ export async function deliverToHerdrSeats(msg: Message, recipients: string[], wh
|
|
|
56
115
|
if (!marker || marker.transport !== HERDR) continue;
|
|
57
116
|
if (where.kind === "room" && marker.rooms === false) continue;
|
|
58
117
|
if (msg.from === agentId) continue;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
118
|
+
// ⛔ ORDER BEFORE SPEED. This path runs in the SENDER's server at send time. If an EARLIER
|
|
119
|
+
// message to this seat is still owed — a paste that failed, which the seat's own tail is
|
|
120
|
+
// retrying — typing this one now and moving the cursor would jump past the owed one, and the
|
|
121
|
+
// tail's retry would find the cursor already beyond it: that message would never be typed.
|
|
122
|
+
// The coordinator's gate named the old call as "still advances the cursor to EOF"; measured,
|
|
123
|
+
// it did exactly that. So: find THIS message after the persisted cursor; if any earlier
|
|
124
|
+
// 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);
|
|
62
143
|
// A control never comes through here: send_command hands a herdr seat's control to the
|
|
63
144
|
// transport's sendControl directly (a raw-vs-rendered branch here was dead and its
|
|
64
145
|
// mutation survived, so it is gone). Everything delivered here is a rendered message.
|
|
65
|
-
let r: { delivered: boolean; error?: string; enters?: number };
|
|
146
|
+
let r: { delivered: boolean; error?: string; enters?: number; verified?: boolean };
|
|
66
147
|
try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, error: (e as Error).message }; }
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const id = safeId(agentId);
|
|
73
|
-
const c = cursor.readPushCursor(ROOT, id) ?? {};
|
|
74
|
-
if (where.kind === "dm") c.inboxOffset = Math.max(Number(c.inboxOffset ?? 0), size);
|
|
75
|
-
else {
|
|
76
|
-
const offsets = ((c.roomOffsets as Record<string, number> | undefined) ?? {});
|
|
77
|
-
offsets[where.chan] = Math.max(Number(offsets[where.chan] ?? 0), size);
|
|
78
|
-
c.roomOffsets = offsets;
|
|
79
|
-
}
|
|
80
|
-
cursor.writePushCursor(ROOT, id, c);
|
|
81
|
-
cursorAdvanced = true;
|
|
82
|
-
} catch { cursorAdvanced = false; }
|
|
83
|
-
}
|
|
84
|
-
out.push({ agentId, delivered: r.delivered, enters: r.enters, error: r.error, cursorAdvanced });
|
|
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 });
|
|
85
153
|
}
|
|
86
154
|
return out;
|
|
87
155
|
}
|