import type { ChatEvent, TurnMessage } from '../../api/chat/types'; import { appendChatEvent, getLastChatEventSeq } from 'officerdb'; import { createTurnStream, type TurnOutput } from './turn-stream'; // The agent sidecar is the writer of record for chat output. // // It used to push raw ChatEvents at officer over the registration socket and let officer translate and // persist them. That socket silently drops when officer is down (`connect.ts:send` — no queue, no // error), so everything the agent produced during a restart was lost: the turn kept running here and // its output went nowhere. Writing to Postgres here instead means an officer restart costs a replay // rather than the output, because the durable record no longer travels over the socket that died. // // Officer still gets every message live — it just gets it already written, with its cursor id attached, // and relays it verbatim. export type Delivery = { sessionId: string; msg: TurnMessage; /** The cursor id under which this message is durable. Absent = ephemeral delta, or the write failed. */ seq?: number; }; export type SessionLogStore = { /** * Translate one parser event and commit + deliver whatever it produces, strictly in order. * `durable: false` skips the write (see ClaudeSpawnStreamingParams.durable) — the messages are still * delivered, just without a cursor, because nothing will ever replay them. */ push: (sessionId: string, event: ChatEvent, durable?: boolean) => void; /** Forget a session's buffer and cursor chain (on kill / clear-session). */ drop: (sessionId: string) => void; /** * Settle every commit queued so far. For shutdown: `push` returns immediately and the durable write * happens on a queue, so exiting without this drops whatever had not reached Postgres yet. */ flush: () => Promise; }; // The durable store, behind an interface so the ordering guarantee below can be tested against a writer // whose writes finish out of order. Defaults to Postgres. export type EventWriter = { append: (sessionId: string, msg: TurnMessage) => Promise; lastSeq: (sessionId: string) => Promise; }; const postgresWriter: EventWriter = { append: (sessionId, msg) => appendChatEvent(sessionId, msg), lastSeq: (sessionId) => getLastChatEventSeq(sessionId), }; type SessionLog = { stream: ReturnType; /** Serialises commits so cursor ids are assigned in the order the events actually arrived. */ tail: Promise; lastSeq: number | undefined; resolvedLastSeq: boolean; }; export function createSessionLogStore( deliver: (d: Delivery) => void, writer: EventWriter = postgresWriter, ): SessionLogStore { const logs = new Map(); function logFor(sessionId: string): SessionLog { let log = logs.get(sessionId); if (!log) { log = { stream: createTurnStream(sessionId), tail: Promise.resolve(), lastSeq: undefined, resolvedLastSeq: false, }; logs.set(sessionId, log); } return log; } async function commit(sessionId: string, log: SessionLog, out: TurnOutput, durable: boolean): Promise { // Deltas are live-only, but still go through the queue: a delta that overtook the `assistant:text` // or `tool:start` in front of it would make the client commit its stream buffer at the wrong point. if (!out.durable || !durable) { deliver({ sessionId, msg: out.msg }); return; } // Pick the chain back up after a restart of this process, so `prevSeq` stays meaningful for a // session that started before it. Once per session; failure just means no continuity claim. if (!log.resolvedLastSeq) { log.resolvedLastSeq = true; try { log.lastSeq = await writer.lastSeq(sessionId); } catch (err) { console.error(`[agent] could not read last event cursor for ${sessionId}:`, err); } } const msg: TurnMessage = log.lastSeq === undefined ? out.msg : { ...out.msg, prevSeq: log.lastSeq }; try { const seq = await writer.append(sessionId, msg); log.lastSeq = seq; deliver({ sessionId, msg, seq }); } catch (err) { // The write IS the durability guarantee, so don't pretend. Deliver live without a cursor: the // client sees the message but won't advance its cursor past something it cannot replay, and the // next successful write chains from the last cursor the client actually holds. console.error(`[agent] failed to persist chat event for ${sessionId}:`, err); deliver({ sessionId, msg: out.msg }); } } return { push(sessionId, event, durable = true) { const log = logFor(sessionId); // Translation is synchronous and therefore in arrival order; only the commit is queued. for (const out of log.stream.push(event)) { log.tail = log.tail.then(() => commit(sessionId, log, out, durable)); } }, drop(sessionId) { logs.delete(sessionId); }, async flush() { // Await the tails as they stand now. A `push` racing this one is not waited for — which is the // honest contract: this exists for shutdown, where the alternative is exiting on an unresolved // write and losing the row that explains why the turn ended. await Promise.allSettled([...logs.values()].map((log) => log.tail)); }, }; }