/** * Restart Drain Service * * Owns the "safe restart" state of the process: * - a delivery pause: once shutdown starts, nothing new is written into an * agent's PTY — messages stay on the persistent queues instead; * - the drain: wait until no agent is mid-turn, capped by a timeout, and * interruptible by a second signal; * - restart readiness for operators (`GET /api/system/restart-readiness`); * - a hook through which the REST restart endpoint asks the server for the * same graceful, drained shutdown a SIGTERM gets. * * "Queue empty" is not "safe to restart": a delivered message is off the * queue while the agent is still working on it (2026-09-24, Ella). Readiness * therefore reports in-flight turns, not queue depth. * * @module services/restart/restart-drain */ import { type InFlightTurn } from './in-flight-turn-tracker.service.js'; /** How a drain ended. */ export type DrainOutcome = 'drained' | 'timed-out' | 'skipped' | 'disabled'; /** Result of a drain. */ export interface DrainResult { /** How it ended */ outcome: DrainOutcome; /** Time spent waiting (ms) */ waitedMs: number; /** Turns still in flight when the drain ended (to be persisted and resumed) */ remaining: InFlightTurn[]; } /** Options for {@link RestartDrainService.drain}. */ export interface DrainOptions { /** Maximum wait (ms); 0 skips the wait */ timeoutMs: number; /** * Longer cap used while any agent waited on has a tool call, subagent or * background task running (ms). Never shortens `timeoutMs`. */ backgroundTimeoutMs?: number; /** Re-check interval (ms) */ pollMs?: number; /** Interval for the repeated "still waiting" log line (ms) */ logIntervalMs?: number; /** Clock (tests) */ now?: () => number; /** Sleep (tests) */ sleep?: (ms: number) => Promise; } /** One agent the readiness endpoint reports as busy. */ export interface BusyAgent { /** Agent session name */ session: string; /** ISO time of the oldest open delivery */ since: string; /** Preview of the most recent open delivery */ messagePreview: string; /** 'runtime': working without a tracked delivery (self-started turn, background work) */ origin?: 'delivery' | 'runtime'; /** A tool call, subagent or background task is running */ longRunning?: boolean; } /** Body of `GET /api/system/restart-readiness`. */ export interface RestartReadiness { /** True when no agent is mid-turn */ safe: boolean; /** Agents mid-turn */ busyAgents: BusyAgent[]; /** Messages waiting on the persistent queues (they survive a restart) */ queued: number; /** True when a shutdown drain is already running */ draining: boolean; } /** Options for a graceful shutdown requested through the service. */ export interface GracefulShutdownRequest { /** Why (for logs) */ reason: string; /** Process exit code at the end of shutdown */ exitCode?: number; /** False skips the in-flight-turn drain (a wind-down already waited for the agents) */ drain?: boolean; } /** * Resolve the drain timeout from the environment. * * @param env - Environment (defaults to process.env) * @returns Timeout in ms; 0 disables the wait. Invalid or negative values fall back to the default. * * @example * ```typescript * resolveRestartDrainMs({ CREWLY_RESTART_DRAIN_MS: '0' }); // 0 * resolveRestartDrainMs({}); // 120000 * ``` */ export declare function resolveRestartDrainMs(env?: NodeJS.ProcessEnv): number; /** * Resolve the longer drain cap used while agents have background work. * * @param env - Environment (defaults to process.env) * @returns Cap in ms; 0 when the drain is disabled; never below the normal drain * * @example * ```typescript * resolveBackgroundDrainMs({}); // 600000 * resolveBackgroundDrainMs({ CREWLY_RESTART_DRAIN_MS: '0' }); // 0 * ``` */ export declare function resolveBackgroundDrainMs(env?: NodeJS.ProcessEnv): number; /** * Process-wide safe-restart state. Singleton. */ export declare class RestartDrainService { private readonly tracker; private static instance; private readonly logger; private paused; private pauseReason; private draining; private windingDown; private windDownOwnsPause; /** Runs inside {@link runAsWindDownNotice}: the wind-down note may pass the delivery pause */ private readonly noticeScope; private skipRequested; private wakeSleeper; private queueCounter; private shutdownHandler; private constructor(); /** * Get the singleton instance. * * @returns The service */ static getInstance(): RestartDrainService; /** * Reset the singleton (tests only). */ static resetInstance(): void; /** * Whether deliveries into agent PTYs are paused (shutdown in progress). * * @returns True while paused */ isDeliveryPaused(): boolean; /** * Whether a wind-down (agents asked to pause before a shutdown / restart) * is running. New work is held while it is: autopilot ticks, reconciler * passes, cron fires and new agent starts check this. * * @returns True during the wind-down */ isWindingDown(): boolean; /** * Start holding new work for a wind-down: deliveries stay on the * persistent queues (owner messages included — they are kept, not lost) * and the {@link isWindingDown} gates close. Idempotent. * * @param reason - Why (for logs) */ beginWindDown(reason: string): void; /** * End the wind-down hold without shutting down (the action failed). Only * lifts a pause the wind-down itself set; a real shutdown keeps its own. */ abortWindDown(): void; /** * Run a delivery of the wind-down note: it passes the delivery pause that * holds everything else. * * @param fn - The delivery * @returns What `fn` returns */ runAsWindDownNotice(fn: () => T): T; /** * Whether the drain wait is running right now. * * @returns True during the wait */ isDraining(): boolean; /** * Stop writing new messages into agent PTYs. Idempotent; never undone in * this process (the next boot starts unpaused). * * @param reason - Why (for logs) */ pauseDelivery(reason: string): void; /** * Ask a running drain to stop waiting (second signal, supervisor escalation). * * @param reason - Why (for logs) * @returns True if a drain was running and will now end */ requestSkip(reason: string): boolean; /** * Register the counter used for `queued` in readiness. * * @param counter - Returns the number of messages on the persistent queues */ setQueueCounter(counter: (() => number) | null): void; /** * Register the server's graceful-shutdown entry point. * * @param handler - Runs the drained shutdown and exits the process */ setShutdownHandler(handler: ((request: GracefulShutdownRequest) => Promise) | null): void; /** * Run the server's graceful (drained) shutdown, if one is registered. * * @param request - Reason and exit code * @returns True if a handler was registered and has been started */ requestGracefulShutdown(request: GracefulShutdownRequest): boolean; /** * Current restart readiness. * * @returns Busy agents, queued count, and whether a restart is safe now */ getReadiness(): RestartReadiness; /** * Wait until no agent is mid-turn, the timeout passes, or a skip arrives. * Pauses delivery first (idempotent) so the set of turns can only shrink. * * @param options - Timeout and test hooks * @returns How it ended and the turns still in flight */ drain(options: DrainOptions): Promise; /** * Sleep, but wake early when a skip is requested. * * @param sleep - Injected sleep (tests), or null for a cancellable timer * @param ms - Duration */ private sleepUnlessSkipped; } //# sourceMappingURL=restart-drain.service.d.ts.map