/** * Per-chat TURN tracking for `POST /chats/:id/messages` (F-RECON-017). * * The engine queues a message sent to a seat that is still answering the previous one — silently: * the pool's `chat_send` answered the seats it accepted, the worker receives the text the instant the previous * turn's final block lands, and the reply frames (`chatDelta` / `chatReply`) carry chat + seat but * NO message correlation. The recon saw exactly that: Q3 sent 58 s into Q2's turn got a 202, and * Q2's reply then rendered under Q3's bubble. Neither the daemon nor the skin could tell. * * This index is the daemon's own record of which seats are mid-turn in which chat: * * - `begin` opens a turn for the seats a send actually reached (the engine's answer) and mints a * `turnId` the 202 returns; * - `inFlight` is the refusal predicate the route consults FIRST: a send that targets a seat still * mid-turn is refused (409 `turn_in_flight`, naming the turn and the busy seats) — targeting * only idle seats passes, so a stalled seat (opencode 0 bytes for 300 s) never blocks a * follow-up to the one that answered; * - `observe` folds the engine's frames: a seat's `chatReply` (ok or not) or `chatSessionFailed` * ends its part of the turn, `chatClosed` drops the chat's turns; * - `decorate` stamps `turn_id` onto the chat frames of a seat mid-turn, so a skin can file a * delta/reply under the message it answers instead of the newest bubble. * * A turn whose frames were lost (an engine restart, a seat reaped without a frame) must never wedge * the chat: a turn older than `staleAfterMs` (default three engine turn budgets — `3 × * WICKED_CHAT_TURN_SECS`, 30 min at the engine's 600 s default; DES-L5 R16) is treated as ended by * every read, and reported in the 409's `stale` reason when it is what let a send pass. * * DES-L5 (F-E2E-041): `begin` runs BEFORE the engine call, in the same tick as `inFlight`, so two * racing sends cannot both pass the predicate and early `chatDelta`s are stamped; `reconcile` then * squares the reserved audience with the seats the engine actually reached, and `abort` retracts a * turn whose send the engine refused. */ import type { CoreEvent } from '../core/types.js'; /** One live turn: the message a set of seats is answering. */ export interface ChatTurn { turnId: string; chatId: string; /** Every seat the send reached (the engine's answer: the PA, or the eligible roster for a random pick). */ seats: string[]; /** The seats that have not ended their part yet. */ pending: string[]; /** Epoch ms of the accepted send. */ startedAt: number; /** A bounded excerpt of the message, for the 409 body and diagnostics. */ excerpt: string; /** * crew#826: how long this turn may go without a closing frame before it reads as lost — * {@link CHAT_TURN_STALE_BUDGETS} of the turn's OWN budget when `begin` was told one (a path * turn: the answer step's `budget_secs`), else of the pool knob ({@link chatTurnStaleAfterMs}). */ staleAfterMs: number; } /** What a refused send is told (the 409 body's `turn`). */ export interface ChatTurnInFlight { turnId: string; chatId: string; seats: string[]; /** The targeted seats still answering — the reason for the refusal. */ busy: string[]; startedAt: number; ageMs: number; excerpt: string; } export declare const CHAT_TURN_EXCERPT_CHARS = 120; /** * The engine's per-turn chat budget default (wicked-core `chat_timeout`, DES-L5 R16 — 600 s, a * hypothesis re-derived in the P6 re-run). The constant MIRRORS core's; the variable is the same * one core-ts reads in this process, so the two agree on any deployment that sets it, and a drift * of the default is a text bug in whichever side moved. */ export declare const CHAT_TURN_BUDGET_SECS_DEFAULT = 600; /** How many engine turn budgets a turn may outlive with no closing frame before it reads as lost. */ export declare const CHAT_TURN_STALE_BUDGETS = 3; /** The per-turn chat budget in seconds: `WICKED_CHAT_TURN_SECS` (a positive integer), else 600. */ export declare function chatTurnBudgetSecs(env?: NodeJS.ProcessEnv): number; /** * Three engine turn budgets, derived from the SAME variable the engine reads: a turn this old with * no closing frame is lost, not live. Consequence (accepted, DES-L5 R16): a turn whose frames were * lost wedges its seat for 30 min at the default (was 15) before `inFlight` treats it as ended — * lost frames are an engine-restart class, and a restart empties the pool anyway. */ export declare function chatTurnStaleAfterMs(env?: NodeJS.ProcessEnv): number; /** The stale ceiling at module load, from the daemon's environment (kept for readers of the constant). */ export declare const CHAT_TURN_STALE_AFTER_MS: number; export declare class ChatTurnIndex { /** chatId → live turns (a seat is in at most one — `inFlight` refuses the second). */ private readonly turns; private readonly now; private readonly staleAfterMs; constructor(opts?: { now?: () => number; staleAfterMs?: number; }); /** Drop turns whose frames were lost (see the module doc); returns the survivors. */ private live; /** * The turn that makes a send to `targets` (every seat of the chat when omitted) refusable: one * whose pending seats intersect the targets. `null` when every targeted seat is idle. */ inFlight(chatId: string, targets?: readonly string[]): ChatTurnInFlight | null; /** * Open a turn for the seats a send is ABOUT to reach (the audience `inFlight` was asked about — * DES-L5: called before the engine call, so the window between the predicate and the record is * closed and early frames are stamped); `reconcile` squares it with the engine's answer. An empty * seat list opens nothing (nothing to wait for). `budgetSecs` is the turn's own budget when the * engine was handed one (a path turn's answer step, crew#826): the turn is lost only after * {@link CHAT_TURN_STALE_BUDGETS} of THAT budget, never of `WICKED_CHAT_TURN_SECS`. */ begin(chatId: string, seats: readonly string[], text: string, budgetSecs?: number): ChatTurn | null; /** * The engine's answer to the send `begin` reserved `turnId` for: `seats` become what the engine * reached; `pending` keeps the reserved seats the engine confirmed and still answering, plus any * seat the engine added that was never reserved — a reserved seat the engine did NOT reach is * dropped (nothing will ever answer for it). An empty answer ends the turn. Returns the turn, or * `null` when it is no longer live. */ reconcile(chatId: string, turnId: string, engineSeats: readonly string[]): ChatTurn | null; /** The engine REFUSED the send `begin` reserved `turnId` for: nothing is answering — retract it. */ abort(chatId: string, turnId: string): void; /** The live turn a seat of a chat is answering, if any. */ turnOf(chatId: string, cliKey: string): ChatTurn | undefined; /** Every live turn of a chat (diagnostics / tests). */ turnsOf(chatId: string): ChatTurn[]; /** Fold one engine frame. Safe on every event type; non-chat frames are ignored. */ observe(event: CoreEvent): void; /** * The frame with `turn_id` stamped when it is a seat's progress on a live turn; the frame itself * otherwise. Call BEFORE `observe` — the closing `chatReply` is the one most worth correlating. */ decorate(event: CoreEvent): CoreEvent; /** The chat is gone (a `DELETE` or an engine reclaim) — nothing left to wait for. */ closed(chatId: string): void; } //# sourceMappingURL=chat-turns.d.ts.map