/** * OSK-3901 (ADR-105) — the managed lease renewal heartbeat. * * ── What it exists to fix ────────────────────────────────────────────────── * * `startManagedInferenceBroker` captures its credential and model scope once at * construction and never refreshes them, and a managed lease is bounded at 15 * minutes. So an agentic turn that ran longer lost authorization mid-flight and * the relay answered `scope-denied`. The API can extend a lease now; nothing was * asking it to. * * ── Renew EARLY, and why that is correctness rather than caution ─────────── * * The reservation reaper selects `{status:'active', expiresAt:{$lte:now}}` and * CASes per row, and the renewal CAS deliberately requires * `expiresAt: {$gt: now}` so it can never resurrect a row the reaper is closing. * Renewing late therefore does not merely risk a slow call — it risks an * UNRENEWABLE lease. The heartbeat fires at a fraction of the window, comfortably * inside the reaper's 60-second interval. * * ── One renewalId per intended extension ────────────────────────────────── * * The server distinguishes a replay from a refusal by checking whether the * winning reservation already carries the exact `renewalId`. So a caller reusing * one id for two DIFFERENT extensions would read the second as an idempotent * no-op and believe it had extended when it had not. Each tick therefore mints * its own id, and a RETRY of that tick reuses it. * * ── Failure discipline ──────────────────────────────────────────────────── * * A denial is terminal and is reported as such. There is deliberately no * fall-back to an ambient credential: managed, BYOK and offline are separate * modes with separate custody, and silently becoming one of the others is the * failure invariant I4 exists to prevent. The loop's job on failure is to stop * and say why, not to keep the session alive by other means. * * ── TWO clocks on ONE heartbeat ─────────────────────────────────────────── * * The lease is not the only thing that expires. The session CAPABILITY that * authenticates every relay call — and this loop's own renewals — has its own, * shorter TTL, and nothing used to refresh it: an hour in, the relay 401ed while * the lease was still well inside the ceiling its policy had bought. * * That refresh rides THIS timer rather than a second one. A second loop would * mean two independent stop conditions for one session, and a session whose lease * heartbeat is alive while its credential heartbeat is dead is a state nobody * would think to look for. The two clocks are checked on the same tick and stay * independent where it matters: a transient capability failure never stops the * lease heartbeat, and only a TERMINAL capability outcome — the ceiling, a * refusal, or our own token having lapsed — ends the session. * * ── Which clock actually reaches the ceiling ─────────────────────────────── * * Both can name it, and on the shipped interactive profile the LEASE gets there * first. The lease renewal is refused once the ceiling is nearer than the window * it would ask for (300s by default), while the capability refresh is refused * only once the clamped grant falls under a 60s floor — so the lease dies about * three minutes before the capability fence could ever speak. That is why the * lease renewal must be able to report `ceiling` in its own right; a design that * carried the ceiling ONLY on the capability path had a stop reason that was * unreachable in the default configuration. */ /** Why a renewal loop stopped. Every value is terminal for the session. */ export type RenewalStopReason = /** `stop()` was called — the session ended normally. */ 'stopped' /** The server refused. Non-retryable: it evaluated and said no. */ | 'denied' /** The transport failed repeatedly. Retryable in principle, exhausted here. */ | 'unavailable' /** * The session reached the ceiling the money behind it was authorized to. * * A DIFFERENT event from `denied`, and the distinction is the whole point of * carrying it: a user whose authorized two hours ran out and a user whose lease * was refused need different sentences and take different actions. The ceiling * is immutable server-side, so retrying can never succeed. * * Reachable from EITHER clock. The capability refresh meets it as a fence * refusal; the lease renewal meets it as the renewal route's own pre-flight * check — and on the shipped interactive profile the lease is the one that gets * there first, by about three minutes. See `managed-inference-renewal.test.ts`. */ | 'ceiling' /** * OUR OWN credential lapsed while this process was not running. * * Not `denied`: nothing was refused, because nothing was evaluated. The modal * cause is a laptop suspend — timers do not fire while asleep, so on wake the * heartbeat presents a token that died hours ago and the server answers 401 to a * question it never really got asked. Telling that user "renewal was refused" * describes a server decision that never happened. */ | 'expired' /** * The MONEY ran out, as opposed to the time (`ceiling`) or the permission * (`denied`). * * A sixth member rather than a flavour of `denied`, because it is the only stop * reason whose remedy can be a WAIT. A spent account window refills, and the * refusal carries `resetAt` saying when; a spent session authorization does not, * and deliberately carries no time at all. Folding either into `denied` would * lose the distinction the human acts on — "buy more / come back at 4pm" versus * "something is wrong with your access". * * This is the reason the badge could report a healthy lease over a dead model: * the heartbeat only ever asked about clocks, so the one event that had actually * ended the session had no way to be reported at all. */ | 'exhausted'; export interface RenewalOutcome { ok: boolean; /** * True only for a refusal the server DECIDED — revoked, reaped, budget spent. * Distinguished from a transport failure because only one of them is worth * retrying, and conflating them is how a spend loop or a premature abort gets * built. */ denied?: boolean; /** * The lease cannot be extended again because its IMMUTABLE ceiling is nearer * than the window the next renewal would ask for. * * A subset of "the server refused" that has to travel separately, because * `denied` covers revoked, reaped and spent identically and none of those is * something the user did on purpose — whereas this one is the session having * used exactly the time it was authorized. */ ceiling?: boolean; /** * The money is spent — the session's own authorization, or the account window * behind it. Terminal like `ceiling`, and separate from it because a clock and * a budget are different things to have run out of. */ exhausted?: boolean; /** * When the spent budget refills, ISO 8601, present ONLY when waiting helps. * * Its absence is information: a session's own authorization never returns, so * an omitted `resetAt` is what tells the message layer to offer no time rather * than to guess one. */ resetAt?: string; /** Milliseconds until the newly-extended lease expires, when the server said. */ expiresInMs?: number; reason?: string; } /** * The outcome of one CAPABILITY refresh. * * Deliberately a richer vocabulary than `RenewalOutcome`'s single boolean. A lease * renewal asks for more TIME against money already authorized; a capability * refresh asks for a new bearer token BOUNDED by that same money. So it has one * outcome the lease renewal does not — the ceiling — and one the lease renewal * never sees, a rate limit, which is the most retryable failure there is and would * be read as a refusal by any classifier working from the status class alone. */ export interface CapabilityRefreshOutcome { ok: boolean; /** * Server-stated absolute expiry of the NEW capability, epoch ms. * * Absolute and server-stated, because the whole schedule hangs off it: the * server may CLAMP the TTL it grants down to the lease's remaining ceiling, so a * client scheduling from its own assumed TTL would drift further out of step the * closer the session got to the thing it needs to track exactly. */ expiresAtMs?: number; /** * The session's authorized ceiling has been reached. Terminal, and NOT the same * event as `denied` — see `RenewalStopReason`. */ ceiling?: boolean; /** * The capability we PRESENTED was already dead, by our own clock. * * Terminal — a refresh authenticates with the token it is replacing, so there * is nothing left to present — but not a decision the server made. See the * `expired` member of `RenewalStopReason`. */ expired?: boolean; /** A refusal the server DECIDED for some other reason. Terminal. */ denied?: boolean; /** Server-stated wait before another attempt can succeed (a 429's `Retry-After`). */ retryAfterMs?: number; reason?: string; } export interface RenewalLoopOptions { /** * Perform one renewal. Injected so the loop is testable without a transport * and so the daemon's wire choice is not baked into the timing policy. */ renew: (input: { renewalId: string; }) => Promise; /** Remaining lease lifetime at start, in ms. */ initialExpiresInMs: number; /** * Replace the session capability, PROACTIVELY. * * Injected for the same reason `renew` is, and additionally because the caller — * not this package — owns the credential the refresh must present and the copy * of it that teardown later uses. Omitted means no capability refresh at all, * which is the correct shape for a caller whose capability outlives its lease. */ refreshCapability?: () => Promise; /** * Absolute expiry (epoch ms) of the capability the loop starts with. * * Without it there is no capability schedule and `refreshCapability` is never * called: a refresh timed by a guess either lands inside the server's rate-limit * window (and cannot succeed) or lands after the token is already dead (and * cannot succeed either), and neither failure is visible until a session breaks. */ capabilityExpiresAtMs?: number; /** Called once, when the loop stops for any reason. */ onStop?: (reason: RenewalStopReason, detail?: string) => void; /** Injected for tests. */ setTimer?: (fn: () => void, ms: number) => NodeJS.Timeout | number; clearTimer?: (handle: NodeJS.Timeout | number) => void; /** Injected for tests; the capability schedule compares absolute instants. */ now?: () => number; /** Transport retries per tick before the loop reports `unavailable`. */ maxTransportRetries?: number; } export interface RenewalLoop { stop(): void; /** Exposed for diagnostics and tests; not part of the session contract. */ readonly renewals: number; } export declare function scheduleRenewalDelay(expiresInMs: number): number; /** * The shortest capability window this lease cadence can still refresh IN TIME. * * The two clocks share one timer, so the capability refresh can only fire on a * lease tick. It comes due with `1 - RENEW_AT_FRACTION` of its window left, and * the worst case is a tick that fired just before that instant — the next one is * a whole lease interval later, and it has to land before the token actually * dies. So the capability window must exceed `leaseTick / (1 - RENEW_AT_FRACTION)`, * which for the shipped 0.6 fraction is 1.5x the lease window. * * Exported because NOTHING pinned this relation and it is settable from two * unrelated places: `OVERSKY_SESSION_TOKEN_TTL_S` sizes the capability, * `OVERSKY_GATEWAY_KEY_TTL` sizes the lease renewal window. At the supported * gateway TTL of 15 minutes, a token TTL of 1350s or less means the FIRST refresh * presents an already-dead token — the feature silently does not apply, in a * configuration that looks configured. The host warns; this function is the * arithmetic it warns from, so the number and the schedule cannot drift apart. */ export declare function minimumCapabilityWindowMs(leaseWindowMs: number): number; export declare function startRenewalLoop(options: RenewalLoopOptions): RenewalLoop;