/** * IN-PROCESS DELIVERY FOR HERDR SEATS (Phase 5.4 Task 4, box 4.4). * * A tmux seat has a pusher that tails its inbox and the rooms it joined, formats a batch, * types it into the pane and advances the PUSH cursor once the paste is verified submitted * (hooks/tmux-pusher.mjs + hooks/push-cursor.mjs). A herdr seat has no pusher, so the * server does the same at SEND time: the message it just appended is formatted by the very * same formatter the pusher uses (hooks/tier.mjs — one renderer, so the pane sees one * shape on either transport), pushed through the transport, and ONLY on a verified * delivery is the seat's push cursor advanced past it. * * THE CURSOR RULE IS THE PUSHER'S, UNCHANGED (⟨q-7be94b5e⟩): a paste that did not reach the * pane must not mark the message consumed. The push cursor is advanced to the offset the * append produced, and never touched when the transport reports not-delivered — the * message stays where `read_messages` still serves it. The READ cursor is the agent's and * is never written here. * * Simplification stated: the pusher batches routine room traffic into digests on a * debounce; a herdr seat receives each message as it is sent, in the digest FORMAT (the * `[agent-coord] …` banner and the attributed line) but one at a time. Tiers still decide * the banner text. Batching for herdr is Task 5 territory if it is wanted. */ import { mkdirSync, statSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { activeTransport, HERDR, type TransportMarker } from "../transports/index.js"; import { ROOT, inboxFile, roomFile } from "../store.js"; import { loadLiveTransports } from "./registry.js"; import { newMessagesIn } from "./jsonl-offsets.js"; import type { Message } from "./shared.js"; export type { Message }; type Tier = { formatBatch: (batch: unknown[], agentId: string, rooms: string[]) => string; classifyTier: (m: unknown, opts?: unknown) => string }; type PushCursor = { readPushCursor: (root: string, safeId: string) => Record; writePushCursor: (root: string, safeId: string, c: Record) => void }; let hooks: Promise<{ tier: Tier; cursor: PushCursor }> | undefined; function loadHooks(): Promise<{ tier: Tier; cursor: PushCursor }> { hooks ??= (async () => { const base = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..", "hooks"); const tier = (await import(pathToFileURL(path.join(base, "tier.mjs")).href)) as Tier; const cursor = (await import(pathToFileURL(path.join(base, "push-cursor.mjs")).href)) as PushCursor; return { tier, cursor }; })(); return hooks; } const safeId = (id: string) => id.replace(/[^A-Za-z0-9._-]/g, "_"); export type Where = { kind: "dm" } | { kind: "room"; chan: string }; /** * ⭐ ONE RENDERER AND ONE CURSOR RULE, SHARED BY BOTH PATHS. The send-time path * (`deliverToHerdrSeats`) and the seat's own tail (`herdr-tail.ts`) must produce the same bytes * in the pane and must move the same cursor the same way — a second copy of either is a second * truth about what a seat has already seen, and the copy is the one that rots. */ export async function renderForPane(msg: Message, agentId: string, where: Where): Promise { const { tier } = await loadHooks(); const tagged = { ...msg, tag: where.kind === "dm" ? "DM" : `room #${where.chan}` }; return tier.formatBatch([{ ...tagged, tier: tier.classifyTier(tagged) }], agentId, where.kind === "room" ? [where.chan] : []); } /** * Advance the PUSH cursor past everything currently in the source. Returns whether it moved. * ⛔ Called ONLY after a verified delivery — that is the rule this whole module exists to keep. */ export async function advancePushCursor(agentId: string, where: Where, toOffset?: number): Promise { try { const { cursor } = await loadHooks(); // ⛔ THE CURSOR DIRECTORY MUST EXIST OR THE WRITE FAILS SILENTLY, AND A CURSOR THAT NEVER // ADVANCES RE-TYPES THE SAME MESSAGE ON EVERY TICK — a paste loop into a live pane. Found by // the tail's own double-delivery test against a fresh bus directory, where `cursors/` had not // been created yet: `writePushCursor` threw ENOENT, the `catch` below turned it into // `false`, and the delivery looked fine from every other angle. mkdirSync(path.join(ROOT, "cursors"), { recursive: true }); const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan); // `toOffset` is the byte just past the line that was delivered. Without it (the send-time path, // where the just-appended message IS the last line) the file size is the same number. const size = toOffset ?? statSync(file).size; const id = safeId(agentId); const c = cursor.readPushCursor(ROOT, id) ?? {}; if (where.kind === "dm") c.inboxOffset = Math.max(Number(c.inboxOffset ?? 0), size); else { const offsets = ((c.roomOffsets as Record | undefined) ?? {}); offsets[where.chan] = Math.max(Number(offsets[where.chan] ?? 0), size); c.roomOffsets = offsets; } cursor.writePushCursor(ROOT, id, c); return true; } catch { return false; } } /** The push cursor as the pusher stores it, for a caller that needs to read an offset. */ export async function readPushCursorFor(agentId: string): Promise> { try { const { cursor } = await loadHooks(); return cursor.readPushCursor(ROOT, safeId(agentId)) ?? {}; } catch { return {}; } } export type HerdrDeliveryOutcome = { agentId: string; delivered: boolean; enters?: number; error?: string; cursorAdvanced: boolean; deferred?: string }; /** Deliver one just-appended message to every herdr-attached recipient among `recipients`. */ export async function deliverToHerdrSeats(msg: Message, recipients: string[], where: { kind: "dm" } | { kind: "room"; chan: string }): Promise { const t = activeTransport(); if (!t || t.kind !== HERDR) return []; let markers: Map; try { markers = await loadLiveTransports(); } catch { return []; } const out: HerdrDeliveryOutcome[] = []; for (const agentId of recipients) { const marker = markers.get(agentId); if (!marker || marker.transport !== HERDR) continue; if (where.kind === "room" && marker.rooms === false) continue; if (msg.from === agentId) continue; // ⛔ ORDER BEFORE SPEED. This path runs in the SENDER's server at send time. If an EARLIER // message to this seat is still owed — a paste that failed, which the seat's own tail is // retrying — typing this one now and moving the cursor would jump past the owed one, and the // tail's retry would find the cursor already beyond it: that message would never be typed. // The coordinator's gate named the old call as "still advances the cursor to EOF"; measured, // it did exactly that. So: find THIS message after the persisted cursor; if any earlier // message to someone else precedes it, defer to the recipient's tail, which delivers in order. const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan); const c = await readPushCursorFor(agentId); const from = where.kind === "dm" ? Number(c.inboxOffset ?? 0) : Number(((c.roomOffsets as Record | undefined) ?? {})[where.chan] ?? 0); const after = newMessagesIn(file, from); const at = after.findIndex((m) => m.msg.id === msg.id); const owed = at === -1 ? [] : after.slice(0, at).filter((m) => m.msg.from !== agentId); if (at === -1 || owed.length > 0) { out.push({ agentId, delivered: false, cursorAdvanced: false, deferred: at === -1 ? "this message is not after the push cursor (already delivered, or the file moved) — left to the recipient's tail" : `${owed.length} earlier message(s) to this seat are still owed — left to the recipient's own tail, which delivers in order`, }); continue; } const rendered = await renderForPane(msg, agentId, where); // A control never comes through here: send_command hands a herdr seat's control to the // transport's sendControl directly (a raw-vs-rendered branch here was dead and its // mutation survived, so it is gone). Everything delivered here is a rendered message. let r: { delivered: boolean; error?: string; enters?: number; verified?: boolean }; try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, error: (e as Error).message }; } // ⟨q-15d763dc⟩ Only a VERIFIED delivery moves the cursor; anything less leaves the message owed. const verified = r.delivered && r.verified === true; // Exactly past THIS message's line — never to the end of the file, which may already hold a later one. const cursorAdvanced = verified ? await advancePushCursor(agentId, where, after[at]!.end) : false; out.push({ agentId, delivered: verified, enters: r.enters, error: r.error ?? (r.delivered ? "delivered but not verified — cursor held (q-15d763dc)" : undefined), cursorAdvanced }); } return out; }