diff --git a/engine/src/cli.attached.test.ts b/engine/src/cli.attached.test.ts index 9ceb0f09..52985ee6 100644 --- a/engine/src/cli.attached.test.ts +++ b/engine/src/cli.attached.test.ts @@ -38,6 +38,9 @@ let authHeaders: Array = []; /** What an `attach` answers with, when a test needs it to differ from `drainResponse` — a * send mints its credential through attach, so those tests need both to be distinct. */ let attachResponse: { status: number; body: unknown } | undefined; +/** What `GET /messages` answers with. `kild watch` reads the log, never the inbox, so its + * tests drive this rather than `drainResponse`. */ +let messagesResponse: { status: number; body: unknown } | undefined; beforeAll(async () => { kildHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'kild-cli-'))); @@ -55,6 +58,9 @@ beforeAll(async () => { if (url.pathname.endsWith('/agents/attach') && attachResponse) { return Response.json(attachResponse.body, { status: attachResponse.status }); } + if (url.pathname.endsWith('/messages') && messagesResponse) { + return Response.json(messagesResponse.body, { status: messagesResponse.status }); + } return Response.json(drainResponse.body, { status: drainResponse.status }); }, }); @@ -420,3 +426,222 @@ test('...but an ordinary `inbox` reports the real cause, not a usage message', a expect(human.stderr).toContain('unreadable attachment record'); expect(human.stderr).not.toContain('usage: kild inbox'); }); + +/** + * `kild watch` — the wake path a turn-end hook cannot provide. Its EXIT CODE is the whole + * interface for whatever background facility runs it, so that is what these assert. + */ +const logMessage = (seq: number, from: string) => ({ + id: `m-${seq}`, + kildId: 'kild-9', + from, + to: ['kild'], + text: 'x', + ts: seq, + seq, +}); + +test('watch exits 0 when somebody else speaks, and says who', async () => { + messagesResponse = { status: 200, body: [logMessage(5, 'claude')] }; + const watched = await runCli([ + 'watch', + 'kild-9', + '--as', + 'kild', + '--since', + '4', + '--timeout', + '3', + ]); + expect(watched.exitCode).toBe(0); + expect(watched.stdout).toContain('@claude'); + messagesResponse = undefined; +}); + +test("watch ignores the watcher's own messages", async () => { + // Otherwise every send would instantly wake the watcher that sent it. + messagesResponse = { status: 200, body: [logMessage(5, 'kild')] }; + const watched = await runCli([ + 'watch', + 'kild-9', + '--as', + 'kild', + '--since', + '4', + '--timeout', + '1', + ]); + expect(watched.exitCode).toBe(2); // quiet, not mail + messagesResponse = undefined; +}); + +test('a quiet engine and a DEAD engine exit differently', async () => { + // The distinction that stops a harness inheriting the silent-failure shape: "nothing + // happened" must never look like "I no longer know". + messagesResponse = { status: 200, body: [] }; + const quiet = await runCli(['watch', 'kild-9', '--as', 'kild', '--since', '1', '--timeout', '1']); + expect(quiet.exitCode).toBe(2); + expect(quiet.stderr).toContain('nothing new'); + messagesResponse = undefined; + + // Nothing listening on this port at all. + const dead = await runCli( + ['watch', 'kild-9', '--as', 'kild', '--since', '1', '--timeout', '30', '--interval', '0.05'], + 'http://127.0.0.1:1', + ); + expect(dead.exitCode).toBe(3); + expect(dead.stderr).toContain('unreachable'); + expect(dead.exitCode).not.toBe(quiet.exitCode); +}); + +test('watch never drains — the hook keeps its mail', async () => { + messagesResponse = { status: 200, body: [logMessage(5, 'claude')] }; + drainRequests = []; + await runCli(['watch', 'kild-9', '--as', 'kild', '--since', '4', '--timeout', '3']); + // A watcher that consumed the inbox would eat exactly what the Stop hook exists to deliver. + expect(drainRequests.map((r) => r.path)).not.toContain('/api/kilds/kild-9/inbox/drain'); + expect(drainRequests.every((r) => r.method === 'GET')).toBe(true); + messagesResponse = undefined; +}); + +test('a dead engine is `unreachable` even with no --since to skip the first fetch', async () => { + // Every other watch test passes --since, which skips the initial cursor fetch entirely — so + // they all walked straight past the one call that was unguarded. Without this, a harness + // starting a watcher against a dead engine got exit 1 (usage) instead of 3 (unreachable), + // which is exactly the distinction these codes exist to make. + const dead = await runCli( + ['watch', 'kild-9', '--as', 'kild', '--timeout', '30', '--interval', '0.05'], + 'http://127.0.0.1:1', + ); + expect(dead.exitCode).toBe(3); + expect(dead.stderr).toContain('unreachable'); +}); + +test('a bad --timeout is a loud usage error, never a silent default', async () => { + const bad = await runCli(['watch', 'kild-9', '--as', 'kild', '--timeout', 'soon']); + expect(bad.exitCode).toBe(1); + expect(bad.stderr).toContain('--timeout'); +}); + +test('watch with nothing to resolve is a usage error', async () => { + const bare = await runCli(['watch']); + expect(bare.exitCode).toBe(1); + expect(bare.stderr).toContain('usage: kild watch'); +}); + +test('watch states plainly that it consumed nothing', async () => { + // The obvious wrong assumption about a wake verb is that it also marks-as-seen. A harness + // built on that would drop every message it was woken for while looking correct, so the + // output has to say otherwise rather than leaving it to be inferred. + messagesResponse = { status: 200, body: [logMessage(5, 'claude')] }; + const watched = await runCli([ + 'watch', + 'kild-9', + '--as', + 'kild', + '--since', + '4', + '--timeout', + '3', + ]); + expect(watched.stdout).toContain('nothing consumed'); + expect(watched.stdout).toContain('kild inbox'); + + const asJson = await runCli([ + 'watch', + 'kild-9', + '--as', + 'kild', + '--since', + '4', + '--timeout', + '3', + '--json', + ]); + expect(JSON.parse(asJson.stdout)).toMatchObject({ consumed: false }); + messagesResponse = undefined; +}); + +test('the "read with" hint reaches the message that woke the watcher', async () => { + // The hint used to be derived by arithmetic on the cursor, which assumed the new messages + // were the highest-numbered ones in the batch. One own-message with a higher seq made the + // printed command skip the very message it was suggesting you read. + messagesResponse = { + status: 200, + body: [logMessage(10, 'kild'), logMessage(11, 'claude'), logMessage(12, 'kild')], + }; + const watched = await runCli([ + 'watch', + 'kild-9', + '--as', + 'kild', + '--since', + '9', + '--timeout', + '3', + ]); + expect(watched.exitCode).toBe(0); + expect(watched.stdout).toContain('--since 10'); // one BELOW the waking message, not 11 + messagesResponse = undefined; +}); + +test('tolerated failures WAIT between attempts instead of spinning', async () => { + // The tolerance existed to give a restarting engine a moment. The retry used `continue`, + // which skipped the sleep entirely, so all three attempts burned in ~8ms and a restart was + // reported as a dead engine — the tolerance inverted into the bug it was meant to prevent. + // Timing is the only thing that distinguishes the fixed loop from the broken one. + const started = Date.now(); + const dead = await runCli( + ['watch', 'kild-9', '--as', 'kild', '--since', '1', '--timeout', '30', '--interval', '0.4'], + 'http://127.0.0.1:1', + ); + const elapsed = Date.now() - started; + expect(dead.exitCode).toBe(3); + // Three attempts means two gaps; anything under one gap means it spun. + expect(elapsed).toBeGreaterThan(700); +}, 10_000); + +test('a bad --interval is a loud usage error', async () => { + const bad = await runCli(['watch', 'kild-9', '--as', 'kild', '--interval', 'fast']); + expect(bad.exitCode).toBe(1); + expect(bad.stderr).toContain('--interval'); +}); + +test('the BOOTSTRAP path respects the window too, not just the poll loop', async () => { + // The fix for the spinning-retry bug unified failure *tolerance* into one helper but left + // the *deadline* check inline at both call sites, and only the loop's copy got it. So a + // single blip during the initial cursor fetch — the default, no-`--since` path — could + // sleep a full interval with no regard for the window: a 1s request took 6s. One decision, + // two copies, one updated. Exactly the shape of the bug it was fixing. + const started = Date.now(); + const overrun = await runCli( + ['watch', 'kild-9', '--as', 'kild', '--timeout', '1', '--interval', '6'], + 'http://127.0.0.1:1', + ); + const elapsed = Date.now() - started; + expect(overrun.exitCode).toBe(2); // quiet — the window closed, the engine was not declared dead + // A 1s window must not become a 6s one because the first fetch happened to fail. + expect(elapsed).toBeLessThan(3_000); +}, 15_000); + +test('the quiet message reports the time that actually elapsed', async () => { + // It printed the requested --timeout verbatim, which made it a lie in precisely the case + // worth reporting: the one where waiting overran the window. A REACHABLE but silent engine + // is what produces `quiet` — a dead one correctly produces `unreachable` instead. + messagesResponse = { status: 200, body: [] }; + const quiet = await runCli([ + 'watch', + 'kild-9', + '--as', + 'kild', + '--since', + '1', + '--timeout', + '1', + '--interval', + '0.2', + ]); + expect(quiet.exitCode).toBe(2); + expect(quiet.stderr).toMatch(/nothing new in \d+s/); + messagesResponse = undefined; +}); diff --git a/engine/src/cli.ts b/engine/src/cli.ts index 32b15fb0..e4f9cf3e 100755 --- a/engine/src/cli.ts +++ b/engine/src/cli.ts @@ -34,6 +34,14 @@ import { import { compactLiveKilds, formatCompactGitSummary } from './kild/kilds-status.ts'; import { GENERAL_PERSONA, listPersonas } from './kild/personas.ts'; import { addProject, findProject, loadProjects, removeProject } from './kild/projects.ts'; +import { + pollResult, + WATCH_DEFAULT_TIMEOUT_S, + WATCH_EXIT, + WATCH_POLL_MS, + WATCH_TOLERATED_FAILURES, + watchSummary, +} from './kild/watch.ts'; const { values, positionals } = parseArgs({ allowPositionals: true, @@ -56,6 +64,8 @@ const { values, positionals } = parseArgs({ execute: { type: 'boolean', default: false }, // `kild land --execute`: merge for real task: { type: 'string' }, // `kild spawn --task `: the new agent's first message session: { type: 'string' }, // `kild attach|inbox|send --session `: the harness session + timeout: { type: 'string' }, // `kild watch --timeout `: how long to wait + interval: { type: 'string' }, // `kild watch --interval `: wake latency vs load }, }); @@ -115,6 +125,8 @@ async function dispatch(): Promise { return kildAttach(action); case 'inbox': return kildInbox(action); + case 'watch': + return kildWatch(action); case 'log': { if (!action) throw new Error('usage: kild log '); return kildLog(action); @@ -127,7 +139,7 @@ async function dispatch(): Promise { return agentsList(); default: console.error( - 'usage: kild …', + 'usage: kild …', ); process.exit(2); } @@ -330,6 +342,154 @@ async function kildAttach(id: string | undefined): Promise { * never stop the operator from finishing a turn. Without the flag it is an ordinary CLI * verb and failures are ordinary loud errors. */ +/** + * `kild watch [ --as ] [--since ] [--timeout ]` — block until + * somebody else speaks in the kild, then exit. + * + * The wake path a turn-end hook cannot provide: a hook fires when a turn ends, so an idle + * harness is unreachable until its operator happens to prompt it. Run this in whatever + * background facility the harness has and react to its exit. + * + * It reads the LOG, never the inbox, so it is non-destructive and cannot eat what the Stop + * hook is there to deliver. Exit codes carry the outcome ({@link WATCH_EXIT}) — in particular + * a quiet engine and a dead one are different codes, so a harness built on this cannot inherit + * the silent-failure shape it exists to remove. + */ +async function kildWatch(idArg: string | undefined): Promise { + const { kildId: id, handle } = await resolveAttachment(idArg, values.as); + if (!id || !handle) { + throw new Error( + 'usage: kild watch [ --as ] [--since ] [--timeout ] [--interval ]\n' + + ' omit id/--as to use the kild this session attached to\n' + + ' waits for somebody else to speak, then exits: 0 mail, 2 quiet, 3 engine unreachable\n' + + ' NON-DESTRUCTIVE — it reads the log and consumes nothing. Your mail is still\n' + + ' waiting afterwards; drain it with `kild inbox`.', + ); + } + const timeout = values.timeout === undefined ? WATCH_DEFAULT_TIMEOUT_S : Number(values.timeout); + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new Error(`--timeout must be a positive number of seconds (got ${values.timeout})`); + } + + // Default to the log's current end, so a watcher started now waits for what happens NEXT + // rather than firing immediately on the conversation it was started in the middle of. + // + // This first call needs the same unreachable handling as the poll loop. Left bare it threw + // to the top-level catch and exited 1 — a *usage* code — so a harness starting a watcher + // against a dead engine could not tell "the engine is gone" from "you typed it wrong". That + // is the distinction the exit codes exist for, defeated at the one moment it matters most. + // Narrowing does not survive into a closure, so pin it once the guard above has run. + const kildId = id; + // Wake latency, and the gap between tolerated retries. Tunable because a harness that + // wants a snappier wake should not have to fork the CLI to get one. + const interval = values.interval === undefined ? WATCH_POLL_MS : Number(values.interval) * 1000; + if (!Number.isFinite(interval) || interval <= 0) { + throw new Error(`--interval must be a positive number of seconds (got ${values.interval})`); + } + const started = Date.now(); + const deadline = started + timeout * 1000; + function sleep(): Promise { + return new Promise((resolve) => setTimeout(resolve, interval)); + } + let failures = 0; + + /** + * Ask once. Returns the batch, or null when the failure was tolerated and the caller should + * wait and try again; exits `unreachable` once tolerance runs out. + * + * ONE place that knows how much transient failure is acceptable, used by both the bootstrap + * fetch and the poll loop. They had separate copies of this decision and immediately drifted: + * the loop retried with no delay at all, so three "tolerated" failures burned in about eight + * milliseconds and a merely-restarting engine was declared dead — the exact opposite of what + * the tolerance exists for — while the bootstrap fetch tolerated nothing whatsoever. + */ + async function poll( + since: number | undefined, + ): Promise> | null> { + try { + const batch = await kildMessages(kildId, since); + failures = 0; + return batch; + } catch (err) { + if (++failures < WATCH_TOLERATED_FAILURES) return null; + console.error(`kild: engine unreachable after ${failures} attempts: ${errText(err)}`); + process.exit(WATCH_EXIT.unreachable); + } + } + + /** + * Wait for the next poll, or report that the window has closed. + * + * The ONE definition of "is there room for another attempt", because there were two and one + * of them was wrong — twice. First the failure path skipped the wait entirely; then the fix + * unified failure *tolerance* into `poll()` but left the *deadline* inline at each call + * site, so the bootstrap fetch had no deadline check at all and a single blip could overrun + * a one-second window by six seconds. Both bugs were the same shape: one decision, two + * copies, one of them updated. + */ + async function waitForNextPoll(): Promise { + if (Date.now() + interval >= deadline) return false; + await sleep(); + return true; + } + + function expire(): never { + // Report the window that actually elapsed. Printing the requested `--timeout` made the + // message a lie in exactly the case worth reporting — the one where waiting overran it. + console.error(`kild: nothing new in ${Math.round((Date.now() - started) / 1000)}s`); + process.exit(WATCH_EXIT.quiet); + } + + // Default to the log's current end, so a watcher started now waits for what happens NEXT + // rather than firing immediately on the conversation it was started in the middle of. A + // blip here is tolerated exactly as one in the loop is — starting an instant before an + // engine finishes restarting must not be reported as an engine that is gone. + let cursor = parseSince(); + while (cursor === undefined) { + const batch = await poll(undefined); + if (batch) cursor = batch.at(-1)?.seq ?? 0; + else if (!(await waitForNextPoll())) expire(); + } + + // Poll FIRST, then sleep: a watcher handed an explicit `--since` that is already behind the + // conversation reacts at once instead of after an interval of avoidable silence. + for (;;) { + const batch = await poll(cursor); + if (!batch) { + // Tolerated failure — wait like any other quiet interval rather than spinning. + if (!(await waitForNextPoll())) break; + continue; + } + const { incoming, cursor: next } = pollResult(batch, handle, cursor); + cursor = next; + if (incoming.length > 0) { + // Say plainly that nothing was consumed. The obvious wrong assumption about a wake verb + // is that it also marks-as-seen, and a harness built on that would drop every message it + // was woken for while looking entirely correct. + console.log( + json + ? JSON.stringify( + { kildId: id, handle, since: cursor, consumed: false, messages: incoming }, + null, + 2, + ) + : `${watchSummary(incoming)} (nothing consumed — still in your inbox)\n` + + // Derived from the OLDEST incoming seq, not from arithmetic on the cursor. + // `cursor - incoming.length` assumed the new messages were the highest-numbered + // ones in the batch, so a single own-message with a higher seq made the printed + // command skip past the very message that woke the watcher — a wrong answer + // handed over with the confidence of a working command. + ` read with: kild log ${id} --since ${Math.min(...incoming.map((m) => m.seq)) - 1}\n` + + ` drain with: kild inbox`, + ); + process.exit(WATCH_EXIT.mail); + } + // Same single definition as the failure path uses: no room for another attempt ends it. + if (!(await waitForNextPoll())) break; + } + expire(); +} + async function kildInbox(idArg: string | undefined): Promise { const claudeStop = values.format === 'claude-stop'; // Omitting both resolves the kild this session attached to — the case the environment diff --git a/engine/src/kild/watch.test.ts b/engine/src/kild/watch.test.ts new file mode 100644 index 00000000..9ff5df58 --- /dev/null +++ b/engine/src/kild/watch.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test'; + +import type { Message } from './kild-types.ts'; +import { pollResult, WATCH_EXIT, watchSummary } from './watch.ts'; + +const message = (seq: number, from: string, to: string[] = ['kild']): Message => ({ + id: `m-${seq}`, + kildId: 'k', + from, + to, + text: 'x', + ts: seq, + seq, +}); + +describe('pollResult', () => { + test('somebody else speaking is a reason to wake', () => { + const { incoming } = pollResult([message(1, 'claude')], 'kild', 0); + expect(incoming).toHaveLength(1); + }); + + test("the watcher's OWN messages never wake it", () => { + // Otherwise every send would immediately re-trigger the watcher that sent it. + const { incoming } = pollResult([message(1, 'kild'), message(2, 'kild')], 'kild', 0); + expect(incoming).toEqual([]); + }); + + test('own messages still advance the cursor', () => { + // Or they would be re-examined on every poll, forever. + const { cursor } = pollResult([message(7, 'kild')], 'kild', 3); + expect(cursor).toBe(7); + }); + + test('a message addressed to someone else still wakes', () => { + // The watcher wants to know the conversation moved. Filtering on `to` would make it deaf + // to everything except its own mail, which is what `inbox` is for. + const { incoming } = pollResult([message(1, 'claude', ['agent'])], 'kild', 0); + expect(incoming).toHaveLength(1); + }); + + test('a mixed batch reports only the others, and advances past all of it', () => { + const batch = [message(4, 'kild'), message(5, 'claude'), message(6, 'kild')]; + const { incoming, cursor } = pollResult(batch, 'kild', 3); + expect(incoming.map((m) => m.seq)).toEqual([5]); + expect(cursor).toBe(6); + }); + + test('an empty batch leaves the cursor exactly where it was', () => { + expect(pollResult([], 'kild', 9)).toEqual({ incoming: [], cursor: 9 }); + }); + + test('a message at or behind the cursor is neither fresh mail nor a cursor rewind', () => { + // `since` is documented exclusive, so this should never arrive — but if it ever does, + // reporting it as new wakes a watcher on already-seen content, repeatedly. Asserting only + // the cursor here left that half completely unconstrained. + const { incoming, cursor } = pollResult([message(2, 'claude')], 'kild', 10); + expect(cursor).toBe(10); + expect(incoming).toEqual([]); + }); + + test('a batch straddling the cursor reports only what is genuinely new', () => { + const batch = [message(9, 'claude'), message(10, 'claude'), message(11, 'claude')]; + const { incoming } = pollResult(batch, 'kild', 10); + expect(incoming.map((m) => m.seq)).toEqual([11]); + }); +}); + +describe('watchSummary', () => { + test('names the sender and counts', () => { + expect(watchSummary([message(1, 'claude')])).toBe('1 new message from @claude'); + }); + + test('pluralises, dedupes senders, and keeps first-appearance order', () => { + const batch = [message(1, 'claude'), message(2, 'agent'), message(3, 'claude')]; + expect(watchSummary(batch)).toBe('3 new messages from @claude, @agent'); + }); +}); + +describe('exit codes', () => { + test('a quiet engine and a dead engine are different codes', () => { + // The whole interface for whatever runs this in the background. If these collided, a + // harness could not tell "nothing happened" from "I no longer know", which is the exact + // silent-failure shape this command exists to remove — and it is not retrofittable. + expect(WATCH_EXIT.quiet).not.toBe(WATCH_EXIT.unreachable); + }); + + test('only mail is success', () => { + expect(WATCH_EXIT.mail).toBe(0); + for (const code of [WATCH_EXIT.usage, WATCH_EXIT.quiet, WATCH_EXIT.unreachable]) { + expect(code).toBeGreaterThan(0); + } + }); + + test('every outcome has a distinct code', () => { + const codes = Object.values(WATCH_EXIT); + expect(new Set(codes).size).toBe(codes.length); + }); +}); diff --git a/engine/src/kild/watch.ts b/engine/src/kild/watch.ts new file mode 100644 index 00000000..930a0dec --- /dev/null +++ b/engine/src/kild/watch.ts @@ -0,0 +1,106 @@ +import type { Message } from './kild-types.ts'; + +/** + * Waiting for somebody else to speak — the wake path a turn-end hook cannot provide. + * + * The Stop hook fires when a turn ENDS. A harness sitting idle is therefore unreachable: mail + * queues, nothing wakes it, and it finds out whenever its operator next happens to prompt. + * kild cannot close that itself — it does not own an attached harness's process, which is the + * founding constraint of an attached agent — and no harness hook helps either, because every + * hook event fires on a turn, tool or session edge and none ticks while a session is idle. + * + * So kild owns the *waiting* and the harness owns the *waking*: this blocks until somebody + * else says something and then exits, and whatever background facility the harness has runs it + * and reacts to that exit. kild learns nothing about the harness; the harness needs to know + * nothing about kild's storage. + * + * **This reads the message LOG, never the inbox.** The log is non-destructive, so a watcher + * cannot eat the mail the Stop hook exists to deliver. `watch` is the wake signal; `inbox` is + * the delivery. Keeping those separate is what makes it safe to run one on a timer. + * + * It also means this needs nothing the engine does not already serve — a cursor, which + * `GET /api/kilds/:id/messages?since=` has always had. Watching answers "has anything new + * arrived?". It deliberately does NOT answer "is that agent sitting on undrained mail?", which + * is a question about delivery state and needs surface that does not exist yet. Conflating + * them would make this depend on machinery it does not need. + */ + +/** + * Timing policy lives HERE, not in `cli.ts`. + * + * `cli.ts` calls `dispatch()` at the top of the module, so a `const` declared further down that + * file is still in its temporal dead zone when the first command reaches it — the failure is + * `Cannot access 'X' before initialization`, and it takes out the whole verb. An imported + * module is fully evaluated before the importing module's body runs, so constants here cannot + * have that problem by construction. That is the class fix; the same bug in its + * function-declaration form has already been paid for once in this file's neighbour. + */ +/** How often to ask. Loopback and non-destructive, so this is a wake-latency budget rather + * than a load one. */ +export const WATCH_POLL_MS = 5_000; +/** Consecutive failed polls before the engine counts as gone rather than blipping. A restart + * takes a moment and must not be reported as a dead engine. */ +export const WATCH_TOLERATED_FAILURES = 3; +/** Default window. Long enough to be useful unattended, short enough that a forgotten watcher + * is not immortal. */ +export const WATCH_DEFAULT_TIMEOUT_S = 1_800; + +/** What one poll concluded. */ +export interface WatchPoll { + /** Messages from somebody other than the watcher, in arrival order. Empty means keep going. */ + incoming: Message[]; + /** Where to resume from. Advances past the watcher's OWN messages too, so its own sends + * are not re-examined on every poll — and can never wake it. */ + cursor: number; +} + +/** + * Decide what a batch of messages means for a watcher. + * + * Pure, because the interesting part is the filtering rule and the cursor arithmetic, not the + * HTTP around it. `handle` is excluded rather than `to` being matched: a watcher wants to know + * that the conversation moved, and a message addressed to someone else in the same kild is + * still a reason to look. Only its own voice is not. + */ +export function pollResult(messages: Message[], handle: string, cursor: number): WatchPoll { + return { + // `seq <= cursor` is dropped as well as own messages. `since` is documented exclusive, so + // a message at or behind the cursor should never arrive — but relying on that means one + // server-side slip re-delivers an already-seen message as fresh mail and wakes a watcher + // on stale content, forever. The guard is one comparison and it makes this function + // correct on its own terms rather than on a promise made elsewhere. + incoming: messages.filter((message) => message.from !== handle && message.seq > cursor), + cursor: messages.reduce((furthest, message) => Math.max(furthest, message.seq), cursor), + }; +} + +/** A one-line summary of who is waiting, for the watcher's stdout. Senders are deduped and + * ordered by first appearance, so the line reads like the conversation did. */ +export function watchSummary(incoming: Message[]): string { + const senders: string[] = []; + for (const message of incoming) if (!senders.includes(message.from)) senders.push(message.from); + const who = senders.map((sender) => `@${sender}`).join(', '); + const count = incoming.length; + return `${count} new message${count === 1 ? '' : 's'} from ${who}`; +} + +/** + * Exit codes, which are the whole interface for whatever runs this in the background. + * + * A quiet engine and a DEAD engine must never look alike. If both exited 0, every harness built + * on `watch` would inherit the failure this command exists to remove: a wake mechanism that + * goes silent and is indistinguishable from one that simply had nothing to report. That is not + * retrofittable once harnesses depend on the behaviour, so it is decided here, first. + */ +export const WATCH_EXIT = { + /** Somebody spoke. The summary is on stdout. */ + mail: 0, + /** Usage, or no attachment to resolve — an ordinary loud CLI failure. */ + usage: 1, + /** The window elapsed with nothing new. The engine was reachable throughout; it was quiet. + * A caller that wants to keep waiting simply runs it again. */ + quiet: 2, + /** The engine stopped answering and did not come back. Distinct from `quiet` because the + * answer is "I no longer know", not "nothing happened". */ + unreachable: 3, +} as const;