make the agent sidecar the writer of record for chat output
officer's registration socket silently drops sends when it isn't OPEN (sidecar/connect.ts:send — no queue, no error, no return value). the agent pushed raw parser events over that socket and officer translated and persisted them, so everything a turn produced while officer was restarting went nowhere: the turn kept running, the output was gone, and a reconnecting client replayed a log that simply had no rows for those seconds. stage 1 kept the agent alive across a restart; this is what makes its output survive one too. move the translation and the write into the sidecar: - turn-stream.ts is the stateful ChatEvent -> browser-message translator lifted out of websocket.ts (delta buffering, flush before tool:start and result). pure and synchronous, so it is unit tested — 12 tests, 100% lines. - session-log.ts commits each message to chat_session_events and only then hands it to officer, with its cursor id attached. per-session promise chain: translation is synchronous and therefore in arrival order, and only the commit is queued, so cursor ids are assigned in the order events actually happened. a delta that overtook the assistant:text in front of it would make the client commit its stream buffer at the wrong point, so deltas go through the same queue even though they are never written. - claude:event on the wire becomes claude:message: a finished browser-facing message plus its seq. officer relays it verbatim and folds it into the in-memory session for sync:messages. it no longer builds or persists chat messages for this harness. gap detection, which is what the durable log is for. chat_session_events.id is a global bigserial, so two consecutive events of one session are not consecutive ids and a client cannot tell a contiguous replay from one with a hole in it. each durable message now carries prevSeq — the cursor of the previous message in the same session — which is inside the persisted payload, so it survives replay. useChat compares it against the cursor it holds before advancing, and surfaces a visible marker on a mismatch: a conversation that silently skips a tool call or half an answer reads as the assistant having done something inexplicable. only checked once a cursor exists, because opening a session from history legitimately starts mid-chain (events are swept after 7 days, the transcript is not). a failed write delivers live with no seq, so the client sees the message but does not advance past something it cannot replay, and the next successful write chains from the cursor the client still holds. pipeline steps pass durable: false. their sessionKey is a throwaway uuid no browser will ever replay and the job's own event log is its record, so writing those rows only grows the table. opencode still goes through officer's createEventHandler, now labelled as such. that is the sidecars-opencode branch. this fixes R4 from CLAUDE_SIDECAR_ISOLATION.md. R3 and R5 already worked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -78,7 +78,12 @@ export {
|
||||
markInterruptedJobs,
|
||||
} from './queries/pipeline-jobs';
|
||||
|
||||
export { appendChatEvent, getChatEventsSince, pruneChatEventsOlderThan } from './queries/chat-events';
|
||||
export {
|
||||
appendChatEvent,
|
||||
getChatEventsSince,
|
||||
getLastChatEventSeq,
|
||||
pruneChatEventsOlderThan,
|
||||
} from './queries/chat-events';
|
||||
|
||||
export {
|
||||
getMusicFavorites,
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { eq, and, gt, asc, lt } from 'drizzle-orm';
|
||||
import { eq, and, gt, asc, desc, lt } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { chatSessionEvents } from '../schema';
|
||||
|
||||
/** Append one outbound event to a session's durable log; returns its global cursor id. */
|
||||
export async function appendChatEvent(sessionId: string, event: unknown): Promise<number> {
|
||||
const [row] = await db
|
||||
.insert(chatSessionEvents)
|
||||
.values({ sessionId, event })
|
||||
.returning({ id: chatSessionEvents.id });
|
||||
const [row] = await db.insert(chatSessionEvents).values({ sessionId, event }).returning({ id: chatSessionEvents.id });
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
@@ -23,6 +20,21 @@ export async function getChatEventsSince(
|
||||
.orderBy(asc(chatSessionEvents.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest cursor id for one session, or undefined if it has no events yet. The writer uses this to
|
||||
* pick its `prevSeq` chain back up after its own restart, so a client can still tell a contiguous
|
||||
* replay from one with a hole in it.
|
||||
*/
|
||||
export async function getLastChatEventSeq(sessionId: string): Promise<number | undefined> {
|
||||
const [row] = await db
|
||||
.select({ id: chatSessionEvents.id })
|
||||
.from(chatSessionEvents)
|
||||
.where(eq(chatSessionEvents.sessionId, sessionId))
|
||||
.orderBy(desc(chatSessionEvents.id))
|
||||
.limit(1);
|
||||
return row?.id;
|
||||
}
|
||||
|
||||
/** Retention: drop events older than the cutoff (called periodically). */
|
||||
export async function pruneChatEventsOlderThan(cutoff: Date): Promise<void> {
|
||||
await db.delete(chatSessionEvents).where(lt(chatSessionEvents.createdAt, cutoff));
|
||||
|
||||
Reference in New Issue
Block a user