feat(backends,storage,core,markdown,infra): claude agent sdk backend, session store, transcript seeding, runner isolation

This commit is contained in:
hh
2026-08-28 01:56:39 +02:00
parent ee2dc918ee
commit e1f242a87a
28 changed files with 2154 additions and 875 deletions
@@ -564,7 +564,7 @@ def _collect_pty_sessions(runtime: GatewayRuntime) -> list[dict[str, Any]]:
"""Enumerate live PTY sessions across all backends.
A backend qualifies if it exposes a ``live_sessions`` mapping
(currently only ``ClaudeCodeBackendAdapter``). Other backend types
(none since the SDK backend; kept for M1b). Other backend types
are quietly skipped — the admin terminal viewer only makes sense for
PTY-backed agents.
+1 -1
View File
@@ -36,7 +36,7 @@ class GatewayRuntime:
the name in hand, so the indirection lives one step earlier.
``mcp_internal_urls`` is filled in Phase 2.1: one loopback URL per
declared ``McpServer`` so ``ClaudeCodeBackendAdapter`` (Phase 2.2)
declared ``McpServer`` so ``ClaudeSdkBackend``
can pass them to ``BackendOptions.mcp_servers`` without re-running
discovery.
@@ -41,7 +41,6 @@ from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from beaver_gateway.backends.claude_code import ClaudeCodeBackendAdapter, TurnCapture
from beaver_gateway.core import audit
from beaver_gateway.core.conversation_store import (
diff_and_fork,
@@ -49,7 +48,9 @@ from beaver_gateway.core.conversation_store import (
load_messages,
mint_conversation,
rewrite_messages,
set_session_id,
)
from beaver_gateway.core.turn_capture import TurnCapture
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.frontends._accumulate import StreamAccumulator
from beaver_gateway.frontends._auth import require_token
@@ -312,8 +313,8 @@ class MarkdownFrontend(Frontend):
content_override: Any,
agent_override: str | None,
) -> Any:
write_disk = content_override is None
if isinstance(content_override, str):
await _write_atomic(file_path, content_override)
file_text = content_override
elif content_override is None:
file_text = await _read_or_empty(file_path)
@@ -381,22 +382,18 @@ class MarkdownFrontend(Frontend):
# Resolve / mint the conversation row, align incoming against
# stored history, and feed the aligned messages to the backend
# see ``core/conversation_store.py`` for the full rationale.
# If the backend isn't claude-code (no ``TurnCapture`` support)
# we fall through to the legacy parser-only path.
# - see ``core/conversation_store.py`` for the full rationale.
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
runtime=runtime, metadata=parsed.metadata, agent_name=agent.name
)
outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns)
capture: TurnCapture | None = (
TurnCapture() if isinstance(backend, ClaudeCodeBackendAdapter) else None
)
kwargs: dict[str, Any] = {}
if capture is not None:
kwargs["capture"] = capture
capture = TurnCapture()
events = backend.complete(
agent=agent, messages=outcome.messages, system=None, **kwargs
agent=agent,
messages=outcome.messages,
system=None,
capture=capture,
**_session_options(conv, outcome.divergence_index),
)
try:
message = await self._stream_to_file(
@@ -405,6 +402,7 @@ class MarkdownFrontend(Frontend):
parsed=parsed,
model=agent.model or agent.name,
filename=filename,
write_disk=write_disk,
)
except HTTPException:
raise
@@ -419,6 +417,7 @@ class MarkdownFrontend(Frontend):
message=message,
agent_name=agent.name,
conv_external_id=conv_external_id,
write_disk=write_disk,
)
await self._persist_canonical_history(
@@ -480,12 +479,10 @@ class MarkdownFrontend(Frontend):
writers in their respective halves of Obsidian Sync. Final
content is identical on both sides, so Sync no-ops.
"""
# File-text resolution + early bailouts. ``content_override`` is
# still written to disk on the gateway side because that's the
# state the rest of the request consumes; it just doesn't keep
# ticking after that single write.
# With ``content`` the plugin is the only writer of the file
# (§3.10): the gateway never touches disk in that case.
write_disk = content_override is None
if isinstance(content_override, str):
await _write_atomic(file_path, content_override)
file_text = content_override
elif content_override is None:
file_text = await _read_or_empty(file_path)
@@ -598,21 +595,19 @@ class MarkdownFrontend(Frontend):
len(outcome.messages),
len(outcome.persist_messages),
)
capture: TurnCapture | None = (
TurnCapture() if isinstance(backend, ClaudeCodeBackendAdapter) else None
)
kwargs: dict[str, Any] = {}
if capture is not None:
kwargs["capture"] = capture
capture = TurnCapture()
_log.info(
"chat/stream: file=%s calling backend.complete agent=%s capture=%s",
"chat/stream: file=%s calling backend.complete agent=%s session=%s",
filename,
agent.name,
capture is not None,
conv.session_id,
)
events = backend.complete(
agent=agent, messages=outcome.messages, system=None, **kwargs
agent=agent,
messages=outcome.messages,
system=None,
capture=capture,
**_session_options(conv, outcome.divergence_index),
)
acc = StreamAccumulator()
@@ -663,9 +658,10 @@ class MarkdownFrontend(Frontend):
new_body, renderer.render_assistant_message(partial)
)
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
await _write_atomic(
file_path, _reattach_frontmatter(parsed.metadata, new_body)
)
if write_disk:
await _write_atomic(
file_path, _reattach_frontmatter(parsed.metadata, new_body)
)
yield _sse_pack(
"error",
{
@@ -683,6 +679,7 @@ class MarkdownFrontend(Frontend):
message=message,
agent_name=agent.name,
conv_external_id=conv_external_id,
write_disk=write_disk,
)
await self._persist_canonical_history(
@@ -727,6 +724,7 @@ class MarkdownFrontend(Frontend):
parsed: parser.ParsedFile,
model: str,
filename: str,
write_disk: bool = True,
) -> Any:
"""Drain ``events`` into a ``Message``, flushing partials to disk.
@@ -745,6 +743,8 @@ class MarkdownFrontend(Frontend):
acc = StreamAccumulator()
async def flush_partial() -> None:
if not write_disk:
return
partial = acc.finalize(model=model)
if not partial.content:
return
@@ -774,9 +774,10 @@ class MarkdownFrontend(Frontend):
new_body, renderer.render_assistant_message(partial)
)
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
await _write_atomic(
file_path, _reattach_frontmatter(parsed.metadata, new_body)
)
if write_disk:
await _write_atomic(
file_path, _reattach_frontmatter(parsed.metadata, new_body)
)
raise
return acc.finalize(model=model)
@@ -788,8 +789,9 @@ class MarkdownFrontend(Frontend):
message: Any,
agent_name: str,
conv_external_id: str,
write_disk: bool = True,
) -> str:
"""Render the assistant turn, append to the file, refresh frontmatter."""
"""Render the assistant turn, refresh frontmatter, write if we own the file."""
rendered = renderer.render_assistant_message(message)
new_body = renderer.append_to_body(parsed.body, rendered)
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
@@ -806,7 +808,8 @@ class MarkdownFrontend(Frontend):
updated_metadata["conversation_id"] = conv_external_id
updated_metadata["fingerprint"] = fingerprint_messages(updated_messages)
new_content = _reattach_frontmatter(updated_metadata, new_body)
await _write_atomic(file_path, new_content)
if write_disk:
await _write_atomic(file_path, new_content)
return new_content
async def _resolve_conversation(
@@ -867,21 +870,17 @@ class MarkdownFrontend(Frontend):
conversation_id: int,
persist_messages: list[dict[str, Any]],
new_user_text: str,
capture: TurnCapture | None,
capture: TurnCapture,
message: Any,
) -> None:
"""Stamp the DB with the post-turn canonical Anthropic-shape history.
Combines the matched/spliced prior state, the new user prompt,
and the synthesized assistanttool cycle from the backend (or
a text-only fallback for backends without ``TurnCapture``).
and the synthesized assistant/tool cycle from the backend (or a
text-only fallback for backends that left ``capture`` empty).
"""
new_user_msg = {"role": "user", "content": new_user_text}
synthesized = (
capture.synthesized_messages
if capture is not None
else _fallback_synthesized(message)
)
synthesized = capture.synthesized_messages or _fallback_synthesized(message)
canonical = [*persist_messages, new_user_msg, *synthesized]
_log.info(
"_persist_canonical_history: conv_id=%d writing %d msgs "
@@ -895,6 +894,12 @@ class MarkdownFrontend(Frontend):
await rewrite_messages(
session, conversation_id=conversation_id, messages=canonical
)
if capture.session_id is not None:
await set_session_id(
session,
conversation_id=conversation_id,
session_id=capture.session_id,
)
_log.info(
"_persist_canonical_history: conv_id=%d DB committed", conversation_id
)
@@ -922,6 +927,19 @@ class MarkdownFrontend(Frontend):
# ---- module-level utilities ----------------------------------------------
def _session_options(conv: Any, divergence_index: int | None) -> dict[str, Any]:
"""Backend options that pin the turn to the conversation's live session.
A divergence means the file's history no longer matches what the
session saw, so the stored ``session_id`` is withheld and the backend
seeds a fresh one from the aligned messages.
"""
return {
"conversation_id": conv.external_id,
"session_id": conv.session_id if divergence_index is None else None,
}
async def _events_with_heartbeat(
events: AsyncIterator[Any], interval: float = _SSE_HEARTBEAT_INTERVAL
) -> AsyncIterator[Any]: