feat: Claude Agent SDK backend + clean-room PostgresSessionStore (v2.7.5)

Lands the lean-SDK direction (boocode_code_review_v2 §1 #9) behind a flag.
Adds @anthropic-ai/claude-agent-sdk@0.3.159 (Commercial Terms, runtime dep).

- PostgresSessionStore: clean-room impl of the SDK's real SessionStore type
  over a new claude_session_entries table. Typechecks against the SDK type;
  8 DB-integration tests.
- ClaudeSdkBackend (implements AgentBackend): one warm query() per (chat,claude)
  in streaming-input mode via a pushable async-iterable pump, sessionStore +
  resume continuity, pure mapSdkMessage->AgentEvent, session_id from init,
  usage/cost onto agent_sessions (backend CHECK gains 'claude_sdk').
- Routing env-gated by CLAUDE_SDK_BACKEND (default off) -> PTY path UNCHANGED.
- Built against real SDK 0.3.159 types (install paid off: partial=stream_event
  needing includePartialMessages, MessageParam, result error arm).
- Fix latent test-infra deadlock: serialize DB suites (fileParallelism:false).

Coder 269 passing default / 290 with DB; tsc clean vs SDK types; builds clean.
LIVE pump + resume + actual claude turn need a host smoke (CLAUDE_SDK_BACKEND=1
+ claude binary + auth). zod peer-dep wants ^4 (workspace 3.25). Builds on v2.7.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 13:37:57 +00:00
parent 7e0ecde83d
commit f3a0197d6a
18 changed files with 1776 additions and 2 deletions

View File

@@ -0,0 +1,96 @@
/**
* claude-sdk-sessionstore #9 (Part 2) — a tiny PURE pushable async-iterable.
*
* The Claude Agent SDK's streaming-input mode wants `query({ prompt })` where
* `prompt` is an `AsyncIterable<SDKUserMessage>`. To keep ONE `query()` generator
* alive across many turns (the "warm" property), the backend feeds it ONE user
* message per `prompt()` turn through a queue that stays open between turns and is
* only closed at `closeSession`/`dispose`. This is that queue.
*
* Semantics (the bit worth unit-testing — push/close/iterate ordering):
* - `push(v)` enqueues a value. If a consumer is parked in `await next()`, it's
* handed the value immediately; otherwise the value buffers in FIFO order.
* - The async iterator yields buffered/pushed values in push order, and PARKS
* (never busy-loops) when the buffer is empty — so the SDK generator waits for
* the next turn's message instead of seeing end-of-input.
* - `close()` ends the iterable: any parked consumer resolves `{done:true}` and
* all future `next()`s return done. Values pushed after close are dropped.
* - It's single-consumer (one `query()` reads it); concurrent consumers are not a
* supported shape and not needed here.
*
* No SDK import — generic over the pushed value `T` — so the pure push/close/iterate
* ordering is testable without the `SDKUserMessage` shape or a live binary.
*/
export interface Pushable<T> {
/** Enqueue a value (or hand it to a parked consumer). No-op after close. */
push(value: T): void;
/** End the iterable. Idempotent; a parked consumer resolves done. */
close(): void;
/** True once `close()` has been called. */
readonly closed: boolean;
/** The async-iterable the consumer (the SDK `query`) drives. */
readonly iterable: AsyncIterable<T>;
}
export function createPushable<T>(): Pushable<T> {
const buffer: T[] = [];
// A waiting consumer's resolver (null when none is parked). Single-consumer.
let pendingResolve: ((res: IteratorResult<T>) => void) | null = null;
let closed = false;
function push(value: T): void {
if (closed) return;
if (pendingResolve) {
const resolve = pendingResolve;
pendingResolve = null;
resolve({ value, done: false });
return;
}
buffer.push(value);
}
function close(): void {
if (closed) return;
closed = true;
if (pendingResolve) {
const resolve = pendingResolve;
pendingResolve = null;
resolve({ value: undefined, done: true });
}
}
const iterator: AsyncIterator<T> = {
next(): Promise<IteratorResult<T>> {
// Drain the buffer first (FIFO), regardless of close — buffered values
// pushed before close are still delivered.
if (buffer.length > 0) {
return Promise.resolve({ value: buffer.shift() as T, done: false });
}
if (closed) {
return Promise.resolve({ value: undefined, done: true });
}
// Park until the next push/close. Single-consumer: only one waiter at a time.
return new Promise<IteratorResult<T>>((resolve) => {
pendingResolve = resolve;
});
},
return(): Promise<IteratorResult<T>> {
// Consumer abandoned the loop (e.g. `break`) → close so a later push no-ops.
close();
return Promise.resolve({ value: undefined, done: true });
},
};
return {
push,
close,
get closed() {
return closed;
},
iterable: {
[Symbol.asyncIterator]() {
return iterator;
},
},
};
}