v2: split session DB into inbound/outbound for write isolation

Eliminates SQLite write contention across the host-container mount
boundary by splitting the single session.db into two files, each with
exactly one writer:

  inbound.db  — host writes (messages_in, delivered tracking)
  outbound.db — container writes (messages_out, processing_ack)

Key changes:
- Host uses even seq numbers, container uses odd (collision-free)
- Container heartbeat via file touch instead of DB UPDATE
- Scheduling MCP tools now emit system actions via messages_out
  (host applies them to inbound.db during delivery)
- Host sweep reads processing_ack + heartbeat file for stale detection
- OneCLI ensureAgent() call added (was missing from v2, caused
  applyContainerConfig to reject unknown agent identifiers)

Verified: tsc clean, 327 tests pass, real e2e through Docker works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-09 12:17:31 +03:00
parent 320176e7e8
commit 82cb363f84
19 changed files with 738 additions and 347 deletions

View File

@@ -4,7 +4,7 @@
* ask_user_question is a blocking tool call — it writes a messages_out row
* with a question card, then polls messages_in for the response.
*/
import { getSessionDb } from '../db/connection.js';
import { findQuestionResponse, markCompleted } from '../db/messages-in.js';
import { writeMessageOut } from '../db/messages-out.js';
import type { McpToolDefinition } from './types.js';
@@ -64,7 +64,7 @@ export const askUserQuestion: McpToolDefinition = {
const questionId = generateId();
const r = routing();
// Write question card to messages_out
// Write question card to outbound.db
writeMessageOut({
id: questionId,
kind: 'chat-sdk',
@@ -81,19 +81,15 @@ export const askUserQuestion: McpToolDefinition = {
log(`ask_user_question: ${questionId} → "${question}" [${options.join(', ')}]`);
// Poll for response in messages_in
// Poll for response in inbound.db (host writes the response there)
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const response = getSessionDb()
.prepare("SELECT content FROM messages_in WHERE kind = 'system' AND content LIKE ? AND status = 'pending' LIMIT 1")
.get(`%"questionId":"${questionId}"%`) as { content: string } | undefined;
const response = findQuestionResponse(questionId);
if (response) {
const parsed = JSON.parse(response.content);
// Mark the response as completed so the poll loop doesn't pick it up
getSessionDb()
.prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE kind = 'system' AND content LIKE ?")
.run(`%"questionId":"${questionId}"%`);
// Mark the response as completed via processing_ack (outbound.db)
markCompleted([response.id]);
log(`ask_user_question response: ${questionId}${parsed.selectedOption}`);
return ok(parsed.selectedOption);