/** * `omp-conductor companion decisions` — the recovery companion pane's own * renderer (#994). * * Why a command and not `watch ''`, which is what recovery used to * spin up: `watch` re-forks its argument every interval, and the * foreground-process transition that produces made herdr repaint the pane — * a flash every 60 seconds, reported live during a fleet restart. Measured at * the time: exactly one full erase at startup in both `watch` and `watch -c`, * ~610 bytes for three renders, and zero ANSI bytes in the command's own * output. So neither `watch`'s drawing nor `-c` was the cause; the re-fork was. * The board pane, a single long-lived process holding its terminal, never * flashed — and this is that shape. * * Two properties are load-bearing: * * - **One process, held.** The pane's foreground stays this process between * renders, which is exactly what recovery's liveness check reads * (`foreground_verdict`: a bare shell means the pane is at a prompt and gets * an `agent start`). A `while`-loop in `sh` would have stopped the flashing * and broken that, because its foreground process is named `sh`. * - **Redraw in place, only on change.** Cursor-home plus erase-to-end-of-line * per line, and the leftover tail of a shorter frame erased explicitly. No * full-screen clear, so there is nothing to see between two identical frames * — the print-and-sleep loop this lineage started with appended instead, and * a pane left open overnight was a mile of identical lists. */ import { companionFrame } from "../companion-view.ts"; import { findProject, loadConfig } from "../config.ts"; import { dbPath, openStore } from "../store.ts"; import type { CommandContext } from "./context.ts"; /** Seconds between reads. The list moves when a human or a condition moves it, * neither of which is fast; a shorter interval would spend the fleet's * process budget to display the same frame. */ export const COMPANION_REFRESH_SECONDS = 60; /** Cursor to home, and erase from the cursor to the end of the line. Nothing * clears the whole screen: a full erase is what a reader perceives as a * flash, and it is unnecessary when every line is overwritten in place. */ const HOME = "\u001B[H"; const ERASE_LINE = "\u001B[K"; const ERASE_BELOW = "\u001B[J"; /** One frame, drawn over whatever the last one left. `previous` is the line * count of the last frame so a shorter one erases its own leftovers instead of * leaving stale rows that read as current. */ export function drawFrame(lines: readonly string[], previous: number): string { const body = lines.map((line) => `${line}${ERASE_LINE}`).join("\n"); // A frame that shrank leaves rows below it; erase from the end of the new // content down rather than clearing first, which would flash. return lines.length < previous ? `${HOME}${body}\n${ERASE_BELOW}` : `${HOME}${body}\n`; } /** * How the pane sleeps out the interval between renders. Production is * {@link timerSleep}; tests inject a fake (#1085) so no real timer runs and * the wait's scheduling is asserted directly. */ export type CompanionSleep = (ms: number, signal: AbortSignal) => Promise; /** The production sleep: one ref'd timeout for the whole interval, ended * early by an abort. Ref'd deliberately (#1085): the pending deadline is the * event loop's reason to sleep out the minute. The old wait unref'd this * timer and polled `stopped` every 250 ms instead — with nothing ref'd left * to wait on, the loop never slept, and the pane burned ~99% of a core * between renders (~10k epoll wakes a second, measured). The unrefs' real * job — never outlive the pane — belongs to cleanup, not to the handle flag: * every exit path here clears the timer and detaches its listener, SIGINT * and SIGTERM abort within microseconds of delivery, and a pane closed by * its terminal still kills the process by signal regardless of all this. * Same shape as `sleepUntilAbort` in commands/tail.ts and board.ts's * `waitForInput`, which never had the spin because its deadline stayed * ref'd. */ export const timerSleep: CompanionSleep = (ms, signal) => { // An already-aborted signal can never fire its listener. if (signal.aborted) return Promise.resolve(); return new Promise((resolve) => { const done = (): void => { clearTimeout(timer); signal.removeEventListener("abort", done); resolve(); }; const timer = setTimeout(done, ms); signal.addEventListener("abort", done, { once: true }); }); }; /** Wait out one refresh interval, ending early the moment `stopped` aborts. * Exactly one sleep is scheduled per render — the poll this replaced * scheduled four a second and spun the process hot between them (#1085). * A stop that has already landed ends the wait before anything is scheduled. * Tests drive the wait through an injected {@link CompanionSleep}. */ export async function waitOutRefreshInterval( stopped: AbortSignal, sleep: CompanionSleep = timerSleep, ): Promise { if (stopped.aborted) return; await sleep(COMPANION_REFRESH_SECONDS * 1_000, stopped); } export async function companionCommand(ctx: CommandContext): Promise { const sub = ctx.argv[1]; if (sub !== "decisions") { process.stderr.write( `omp-conductor: unknown companion view "${sub ?? ""}" — expected decisions\n`, ); process.exit(2); } const project = findProject(loadConfig(), ctx.projectFlag); const once = ctx.argv.includes("--once"); let previous = 0; const stopped = new AbortController(); const stop = (): void => { stopped.abort(); }; process.on("SIGINT", stop); process.on("SIGTERM", stop); while (!stopped.signal.aborted) { // Opened per render, closed immediately: a companion pane lives for days, // and a handle held that long across daemon restarts and db snapshots is a // handle to a file that may no longer be the store. const store = openStore(dbPath()); let lines: string[]; try { lines = companionFrame(store.openDecisions(project.name)); } finally { store.close(); } process.stdout.write(drawFrame(lines, previous)); previous = lines.length; if (once) return; // One ref'd sleep for the whole minute, aborted early on SIGINT/SIGTERM // — see timerSleep above for why it must stay ref'd (#1085). await waitOutRefreshInterval(stopped.signal); } }