/** * Pure polling loop for `dench-fs-daemon flush`. * * Lives in its own module so the unit tests can pull `runFlushPolling` * without importing `cli/fs-daemon.ts`'s top-level `main()` (which * would try to start the actual daemon and crash the test runner). * * The flush command's whole job is to make `execute_bash` writes * deterministically visible in Convex BEFORE chat-turn returns the * tool result to the model. The polling loop is responsible for: * * 1. Bouncing immediately when no daemon is registered or the * registered pid is dead. Otherwise chat-turn would hang for * timeoutMs every turn on a host where the daemon never came up. * 2. Sending exactly one SIGUSR1 (the daemon's runDaemon registers a * handler that triggers an immediate reconcile tick). * 3. Treating the flush as complete only when BOTH conditions hold: * a) `lastReconcile.finishedAt > t0` (proves the signal-driven * tick actually ran and not just an old scheduled tick). * b) `pendingDrain.{pendingPaths, inFlightUploads}` are both 0 * (proves every per-path debounce timer fired AND the upload * it triggered drained — without (b) we'd race a pending * 300 ms debounce or an in-flight Convex Storage POST). * 4. Surfacing a structured timeout result with the last-seen drain * counts so the caller (chat-turn) can decide whether to flag * drift to the user. */ /** * Snapshot of how much work the daemon still has in flight. The * `flush` subcommand polls this via the status file and only declares * success when both numbers are zero AND a fresh reconcile has * completed since the flush request. */ export type DrainStatus = { /** Paths with a debounce timer set but the upload hasn't started. */ pendingPaths: number; /** Upload calls currently mid-flight (between fn() start and finally). */ inFlightUploads: number; }; /** * Subset of the daemon status file the flush poller needs. The full * shape lives in `cli/fs-daemon.ts`; we accept any superset here so * upstream changes to the status file don't ripple into this module. */ export type FlushPollingStatus = { pid: number; lastReconcile: { finishedAt: number } | null; pendingDrain: DrainStatus; }; export type FlushOutcome = | { ok: true; reason: "flushed"; pid: number; durationMs: number } | { ok: false; reason: "no_daemon"; pid: number | null; durationMs: number } | { ok: false; reason: "signal_failed"; pid: number; durationMs: number; error: string; } | { ok: false; reason: "timeout"; pid: number; durationMs: number; drain: DrainStatus; }; export const FLUSH_POLL_INTERVAL_MS = 50; export const FLUSH_DEFAULT_TIMEOUT_MS = 5_000; export type FlushPollingArgs = { timeoutMs: number; readStatus: () => Promise; isPidAlive: (pid: number) => boolean; signal: (pid: number) => void; now?: () => number; sleep?: (ms: number) => Promise; pollIntervalMs?: number; }; export async function runFlushPolling( args: FlushPollingArgs, ): Promise { const now = args.now ?? (() => Date.now()); const sleep = args.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); const pollMs = args.pollIntervalMs ?? FLUSH_POLL_INTERVAL_MS; const t0 = now(); const initial = await args.readStatus(); if (!initial) { return { ok: false, reason: "no_daemon", pid: null, durationMs: now() - t0, }; } const pid = initial.pid; if (!args.isPidAlive(pid)) { return { ok: false, reason: "no_daemon", pid, durationMs: now() - t0 }; } try { args.signal(pid); } catch (error) { return { ok: false, reason: "signal_failed", pid, durationMs: now() - t0, error: error instanceof Error ? error.message : String(error), }; } const deadline = t0 + args.timeoutMs; let lastDrain: DrainStatus = initial.pendingDrain; while (now() < deadline) { await sleep(pollMs); const current = await args.readStatus(); if (!current) continue; lastDrain = current.pendingDrain; const finishedAfterT0 = (current.lastReconcile?.finishedAt ?? 0) > t0; const drained = current.pendingDrain.pendingPaths === 0 && current.pendingDrain.inFlightUploads === 0; if (finishedAfterT0 && drained) { return { ok: true, reason: "flushed", pid, durationMs: now() - t0 }; } } return { ok: false, reason: "timeout", pid, durationMs: now() - t0, drain: lastDrain, }; }