/** * The delivery ledger: an answer is owned here until WeChat has it. * * `agent_end` produces text; that is not the same as the owner reading it. * Between the two sit a worker that may be down, a send that may fail in * five distinguishable ways, a retry that may only fire for the failures * that prove nothing left this process, and a park timeout that must bound * the wait for a channel which may never come back. All of it used to be * five `let`s and two functions in `index.ts`'s closure, reachable from six * events — and the bugs it has had were never in the arithmetic (that is * `policy.ts`, and it is tested) but in the calling: a second event sending * the same answer twice, a park clock that was never started, a debounced * write on the settle path. * * So this module is the *calling*, and it takes everything else as an * argument: the clock, the two pure decisions, the transport, the readiness * predicate, persistence, and the two voices (owner, operator). Nothing it * imports survives compilation — every import here is `import type`, which * is what lets `test/delivery.test.ts` load it under node's type stripper * and drive the whole machine on a fake clock. That is the difference * between this and the `WorkerManager` extraction that was reverted * (69f3a28 → 9975ba8): moving state out of a closure buys nothing on its * own; moving it out *with its dependencies unhooked* is what buys the test. * * Three things the rest of the channel must keep believing: * * - `processing` stays true from `owe()` until `ops.onSettled()`. This * module never reads or writes it — settling is an event, not a state, * and the pipeline that owns the flag is the one that turns it off. * - Only `{type:'reply'}` ever leaves here. A notice must not enter the * ledger: the ledger holds the queue closed until its text is acked, so * a courtesy message routed through it would block the queue it is * describing (CLAUDE.md, ADR-004 §3). * - The settle path persists *synchronously*. See `persistNow` below. */ import type { ExtToWorkerCmd } from '../ipc.js'; import type { Logger } from '../log.js'; import type { DeliveryVerdict, ParkedDeliveryPlan } from '../policy.js'; import type { PendingDeliveryRecord, RestoredDelivery } from '../state.js'; import type { OwnerNotice } from '../ui.js'; import type { ChannelState, NotifyLevel, Owns } from './core.js'; import type { Timers } from './timers.js'; /** * The pure decisions, injected rather than imported. * * `planRestoredDelivery` is here beside the other two even though it belongs * to `state.ts` rather than `policy.ts`: it is a function, so a type-only * import cannot reach it, and the alternative — having the restore path run it * and hand in the verdict — would split the three outcomes of an inherited * answer across two files. One of those outcomes drops the answer, and every * drop in this channel has to leave the same trace (`lastDroppedDelivery`, * the deferred owner notice, the log line). Keeping all three inside * `inherit()` is what keeps that trace in one place. */ export interface DeliveryPolicy { classifyDeliveryFailure(errMsg: string, attempts: number, maxAttempts: number, retryDelayMs: number): DeliveryVerdict; planParkedDelivery(parkedSince: number | null | undefined, nowMs: number, parkTimeoutMs: number, maxRecheckMs: number): ParkedDeliveryPlan; planRestoredDelivery(delivery: PendingDeliveryRecord | null): RestoredDelivery; } /** The tunables, read once by the caller so this module holds no config. */ export interface DeliveryLimits { maxAttempts: number; retryDelayMs: number; parkTimeoutMs: number; parkRecheckMs: number; /** Whether the owner hears about an answer that was thrown away. */ notifyFailures: boolean; } /** Everything this module makes happen outside its own state. */ export interface DeliveryOps { /** * Write the mirror now. * * Deliberately *not* the same entry point as `persistSoon`, and the two * must never be merged: a settle that went through the 250 ms debounce * leaves a window in which a SIGKILL resurrects an answer the owner has * already read, and re-sending a delivered answer is the exact failure the * whole allowlist in `policy.ts` exists to prevent. The symptom is * probabilistic, crash-only, and invisible in the log. */ persistNow(): void; /** Coalesced write. Only the retry branch may use it — nothing is at risk * there: the answer is still in the record either way. */ persistSoon(): void; /** `sendStop` = no reply will follow, so the indicator needs an explicit * off; a delivered reply cancels it worker-side already. */ stopTyping(sendStop: boolean): void; notifyOwner(userId: string, notice: OwnerNotice, opts?: { defer?: boolean; }): void; notifyOperator(message: string, level: NotifyLevel): void; /** This turn is over: the caller clears `processing`, repaints, and pulls * the next message. Fires exactly once per owed answer. */ onSettled(): void; } export interface DeliveryDeps { S: Owns; log: Logger; timers: Timers; policy: DeliveryPolicy; limits: DeliveryLimits; /** Tracked send: it resolves on the worker's ack and rejects on * backpressure, a failed write, a timeout, or a failed ack. The * distinction between those is the whole input to the retry decision. */ send(cmd: ExtToWorkerCmd): Promise; /** Worker present, ready, and logged in. */ channelReady(): boolean; } export interface DeliveryLedger { /** Take ownership of an answer the agent just produced. */ owe(userId: string, text: string): Promise; /** * Take on whatever the previous instance left undelivered. * * Returns whether the queue must stay closed behind it. It attempts a * delivery itself rather than waiting for a `nudge()`, and that is not an * optimisation: the park clock is armed *inside* the attempt, so an * inherited answer in an instance whose worker never starts (no build, no * credentials, `autoLogin` off) would otherwise hold the queue closed for * the rest of the session with no bound at all. */ inherit(record: PendingDeliveryRecord | null): boolean; /** Something changed that might let a parked answer through. */ nudge(): Promise; /** Retirement: stop the timers, and mark a send whose ack we will never * see, so the next instance drops it rather than resending it. */ detach(): void; /** Is an answer owed? The gate on steering a turn that is already over. */ owes(): boolean; progress(): { attempts: number; maxAttempts: number; } | null; } export declare function createDeliveryLedger(deps: DeliveryDeps, ops: DeliveryOps): DeliveryLedger; //# sourceMappingURL=delivery.d.ts.map