/** * spine-intake.ts, how a hosted session reaches the SHARED session spine, and * how a steer reaches a hosted turn. * * Two jobs, together because they are the same relationship seen from both * ends. * * REGISTRATION puts a hosted session in `sessions.list` beside every other * kind, so a client that lists sessions sees the ones the daemon is running * rather than only the ones its own process started. * * INTAKE is what makes `sessions.steer` and `sessions.followUp` actually drive * a hosted turn without a parallel verb family. The broker routes a steer at a * session with a live SURFACE participant to that surface to collect; for a * hosted session, this engine is the surface. So it collects the queued inputs * and hands each to the loop, the same contract `createWireSessionDispatch` * implements for a client across the wire, with the one difference that this * one shares a process with the broker. * * The heartbeat is not decoration. A steer goes to a live surface participant * when there is one and spawns a background AGENT when there is not, so a * hosted session whose participant went stale would quietly stop receiving its * own steers and start getting agents instead, the conversation would keep * answering, from the wrong thing. */ import type { HostedSessionRecord } from './types.js'; /** * The narrow view of the shared session broker hosted sessions use. * * Registration is what puts a hosted session in `sessions.list` beside every * other kind. The input methods are what make `sessions.steer` and * `sessions.followUp` DRIVE one: a steer at a session with a live surface * participant is queued FOR that surface to collect, and for a hosted session * this engine is the surface. Collecting and delivering those queued inputs is * the same contract `createWireSessionDispatch` implements for a client on the * other side of the wire, the difference is only that this one is in the same * process as the broker. */ export interface HostedSessionSpine { register(input: { readonly sessionId: string; readonly kind: 'hosted'; readonly project?: string | undefined; readonly title?: string | undefined; readonly participant: { readonly surfaceKind: 'service'; readonly surfaceId: string; readonly lastSeenAt: number; }; }): Promise; closeSession(sessionId: string): Promise; /** Inputs waiting for this surface to collect. */ getInputsSince(sessionId: string, options: { readonly state?: 'queued' | undefined; }): readonly { readonly id: string; readonly body: string; }[]; /** Report one collected (`consumed:false`) or finished (`consumed:true`). */ markInputDelivered(sessionId: string, inputId: string, options?: { readonly consumed?: boolean | undefined; }): Promise; /** * Report one this surface collected and could not act on, with the reason. * * Optional so a stand-in spine in a test need not implement it; a spine * without it leaves the record collected-but-unfinished, which is still * honest, what must never happen is marking it consumed. */ failInput?(sessionId: string, inputId: string, error: string): Promise; } /** What the intake needs from the engine that owns the sessions. */ export interface HostedSessionSpineIntakeOptions { readonly spine?: HostedSessionSpine | undefined; /** Non-terminated hosted sessions, read fresh on every tick. */ readonly liveSessions: () => readonly HostedSessionRecord[]; /** Hand one collected input to its session's loop. */ readonly deliver: (sessionId: string, text: string) => Promise; readonly now: () => number; /** Tick interval. Default 750ms, the order every inbound-dispatch client here uses. */ readonly intervalMs?: number | undefined; /** Delivery attempts one collected input gets before it is failed. Default 3. */ readonly maxDeliveryAttempts?: number | undefined; /** * Put one line in front of the owner over a channel that still works. * Omitted ⇒ an undeliverable message is logged and recorded on the spine but * nobody is told, which is the state a survive-detach session must not be in. */ readonly alertOwner?: ((text: string) => void) | undefined; } export declare class HostedSessionSpineIntake { private readonly options; private timer; private scheduling; private stopped; /** Collected inputs whose delivery has not succeeded yet, by session+input. */ private readonly pending; /** In-flight or queued deliveries, by session+input, so a tick never schedules one twice. */ private readonly inFlight; /** One delivery lane per session with work outstanding. */ private readonly lanes; constructor(options: HostedSessionSpineIntakeOptions); /** Begin collecting and heartbeating. A no-op without a spine. */ start(): void; stop(): void; /** Put (or refresh) this session on the shared spine. Never throws. */ register(record: HostedSessionRecord): Promise; /** Close this session's shared-spine record. Never throws. */ close(sessionId: string): Promise; /** * One pass: heartbeat every live session, then collect its queued inputs and * hand them to that session's delivery lane. * * What this method must NOT do is wait for a delivery. `deliver` runs the * whole turn, minutes, on a real one, and this used to be awaited inside * the re-entrancy guard, so for as long as any ONE hosted session was * answering, no other session was heartbeated. A hosted session whose * participant goes stale stops receiving its own steers and starts getting * background agents instead: the conversation keeps answering, from the wrong * thing. The stale clocks also read as idle to the session reaper, which is * how a session with a turn plainly still running was closed "idle-reaped". * * So the guard now covers SCHEDULING only. Delivery is detached into a * per-session lane, ordering within a session is the lane's job, and errors * out of a detached delivery are reported exactly where a thrown delivery * used to be reported from here. */ tick(): Promise; /** * Wait for every delivery now in flight. * * The tick no longer waits for delivery, so a caller that needs the OUTCOME *, a test asserting what the spine recorded, a shutdown that would rather * not abandon a message mid-flight, asks for it explicitly instead of * relying on tick() to have finished the work. */ drainDeliveries(): Promise; /** Put one delivery in its session's lane and make sure the lane is draining. */ private enqueueDelivery; /** * Drain one session's lane, serially, detached from the tick that filled it. * * The lane's task never rejects: a delivery that fails is handled by * attemptDelivery, and anything that escapes it is reported where tick() * used to report it. Nobody awaits this task in normal operation, so a * rejection here would be an unhandled one, the failure mode this whole * class already exists to keep off a daemon that treats those as fatal. */ private startLane; /** * Hand one collected input to its session's loop. * * Delivery failing is NOT the same as delivery happening: marking the input * consumed either way is a record saying the owner's message was answered * when nothing received it, and on a survive-detach session with nobody * attached the only trace was a warn line in the daemon log. So a failure * keeps the input, retries it on the next tick, and, once the attempts are * spent, fails it on the spine and puts the incident in front of the owner. */ private attemptDelivery; /** Say it on a channel that still works. Never throws into the tick. */ private alertOwner; } //# sourceMappingURL=spine-intake.d.ts.map