/** * facade-cluster-sockets.ts, contesting a socket surface under its REAL name. * * Slack and Discord are the awkward pair. An ntfy topic and a Telegram bot id * are in the configuration, so a node knows what it is contesting before it * contacts anything. A Slack workspace and a Discord application are not: the * config holds a token, and the workspace that token belongs to is a fact only * the provider can tell you. * * The tempting shortcut is a fixed placeholder, every node contesting * "the Slack surface". That is a starvation bug, not a conservatism. Two nodes * configured for two DIFFERENT workspaces would contest one election, one of * them would lose, and its workspace would go unanswered with nothing anywhere * to say why. That silence is precisely what per-surface elections exist to * eliminate, so it cannot be reintroduced by the naming. * * So the identity is resolved first, and it is resolved WITHOUT consuming * anything: Slack's `auth.test` and Discord's `/users/@me` are authenticated * REST calls that open no socket and deliver no events. The surface is * therefore contested under its true name from the first datagram, and there * is never a window in which this node is reading a workspace it has not won. * * Two failure shapes are handled here, both of which end in the same place, * this node stops contesting a surface it cannot serve: * * - The identity does not resolve (no token, a revoked token, the provider * unreachable). Nothing is registered, so this node cannot win it, and the * reason is stated at ERROR rather than left to be inferred from silence. * * - The identity resolved, the surface was won, and then the socket dropped. * The gate is withdrawn, which runs the ordinary ordered stand-down: the * consumer stops, a RESIGN goes out, and another machine can take the * workspace immediately instead of waiting out the crash timeout. * * Both retry, because neither is necessarily permanent, a provider outage * ends, a network comes back, and a node that gave up permanently on a * transient failure would leave a surface unread the moment the last other * node went away. * * Retrying forever is not the same as complaining forever. A workspace whose * token was revoked and never replaced is a permanent condition, and a fixed * retry timer that restates the same ERROR every minute buries the log it is * trying to make legible, the same failure the activity logger has already * been taught not to commit. So two things are separated here: * * - The RETRY interval backs off, base delay doubling up to a ceiling, with * equal jitter so a group of nodes that all lost the same provider do not * re-ask it in lockstep. * * - The ERROR is stated ONCE per unbroken run of failures. Later attempts in * the same run go to debug with the attempt count, and the run is closed * out by an info line when the identity finally resolves, so the operator * sees both the onset and the recovery, and nothing in between. * * A socket that drops is deliberately NOT put behind the same backoff: the * first attempt after a lost socket is prompt, because a reconnect usually * succeeds immediately. If the provider really is down, that prompt attempt * fails to resolve the identity and the identity backoff takes over from * there, which is the escalation the situation actually calls for. */ import { type ClusterSurfaceKey } from '../cluster/index.js'; export interface SocketSurfaceSupervisorOptions { readonly kind: 'slack' | 'discord'; /** * The provider's own name for what this token reads, a Slack team id, a * Discord application id, or null when that cannot be established. */ readonly resolveIdentity: () => Promise; /** Register the gate for a surface; returns the withdraw function. */ readonly register: (surface: ClusterSurfaceKey) => () => void; /** False once the daemon is shutting down, so retries stop. */ readonly isRunning: () => boolean; readonly reportInert: (surface: string, action: string) => void; readonly logger: { info(message: string, meta?: Record): void; debug(message: string, meta?: Record): void; }; /** Injected so tests need no real timers. Returns its own cancel function. */ readonly setTimer?: ((fn: () => void, ms: number) => () => void) | undefined; /** Base retry delay; the first retry of a failure run waits about this long. */ readonly retryMs?: number | undefined; /** Ceiling the backoff climbs to. */ readonly maxRetryMs?: number | undefined; /** Jitter source in [0, 1). Injected so a test can pin the delay exactly. */ readonly random?: (() => number) | undefined; } export declare class SocketSurfaceSupervisor { private readonly options; private withdraw; private cancelRetry; private identity; private disposed; /** Identity lookups that have failed in a row; 0 once one succeeds. */ private identityFailures; /** True once the current run of failures has been stated at ERROR. */ private reportedInert; constructor(options: SocketSurfaceSupervisorOptions); /** The surface this node is currently contesting, or null. */ get contestedSurface(): ClusterSurfaceKey | null; /** * Resolve the identity and, if it is real, contest that surface. * * Safe to call repeatedly: an already-registered surface is left alone * rather than registered twice. */ begin(): Promise; /** * The socket dropped. Stand down from the surface and try to get it back. * * Withdrawing runs the ordered stand-down inside the registry, the consumer * is stopped and only then is the RESIGN broadcast, so the successor never * starts against a consumer that is still running. */ onSocketLost(reason: string): void; /** Stop retrying and withdraw. Called when the daemon shuts down. */ dispose(): void; /** * The delay for the next identity attempt: the base doubling per consecutive * failure up to the ceiling, then equal jitter so that a group of nodes which * all lost the same provider do not come back at it in one wave. */ private nextRetryMs; /** * @param retryMs Delay to use. Omitted after a lost socket, which retries at * the base delay: a reconnect usually works at once, and if the provider is * genuinely down the identity lookup will fail and start the backoff itself. */ private scheduleRetry; } //# sourceMappingURL=facade-cluster-sockets.d.ts.map