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);

View File

@@ -1,10 +1,12 @@
/**
* Scheduling MCP tools: schedule_task, list_tasks, cancel_task, pause_task, resume_task.
*
* Tasks are messages_in rows with process_after timestamps and optional recurrence.
* The host sweep detects due tasks and wakes the container.
* With the two-DB split, the container cannot write to inbound.db (host-owned).
* Scheduling operations are sent as system actions via messages_out — the host
* reads them during delivery and applies the changes to inbound.db.
*/
import { getSessionDb } from '../db/connection.js';
import { getInboundDb } from '../db/connection.js';
import { writeMessageOut } from '../db/messages-out.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
@@ -57,22 +59,22 @@ export const scheduleTask: McpToolDefinition = {
const recurrence = (args.recurrence as string) || null;
const script = (args.script as string) || null;
const content = JSON.stringify({ prompt, script });
getSessionDb()
.prepare(
`INSERT INTO messages_in (id, timestamp, status, status_changed, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content)
VALUES (@id, datetime('now'), 'pending', datetime('now'), 0, @process_after, @recurrence, 'task', @platform_id, @channel_type, @thread_id, @content)`,
)
.run({
id,
process_after: processAfter,
// Write as a system action — host will insert into inbound.db
writeMessageOut({
id,
kind: 'system',
platform_id: r.platform_id,
channel_type: r.channel_type,
thread_id: r.thread_id,
content: JSON.stringify({
action: 'schedule_task',
taskId: id,
prompt,
script,
processAfter,
recurrence,
platform_id: r.platform_id,
channel_type: r.channel_type,
thread_id: r.thread_id,
content,
});
}),
});
log(`schedule_task: ${id} at ${processAfter}${recurrence ? ` (recurring: ${recurrence})` : ''}`);
return ok(`Task scheduled (id: ${id}, runs at: ${processAfter}${recurrence ? `, recurrence: ${recurrence}` : ''})`);
@@ -92,13 +94,14 @@ export const listTasks: McpToolDefinition = {
},
async handler(args) {
const status = args.status as string | undefined;
const db = getInboundDb();
let rows;
if (status) {
rows = getSessionDb()
rows = db
.prepare("SELECT id, status, process_after, recurrence, content FROM messages_in WHERE kind = 'task' AND status = ? ORDER BY process_after ASC")
.all(status);
} else {
rows = getSessionDb()
rows = db
.prepare("SELECT id, status, process_after, recurrence, content FROM messages_in WHERE kind = 'task' AND status NOT IN ('completed') ORDER BY process_after ASC")
.all();
}
@@ -131,14 +134,15 @@ export const cancelTask: McpToolDefinition = {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
const result = getSessionDb()
.prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status IN ('pending', 'paused')")
.run(taskId);
if (result.changes === 0) return err(`Task not found or not cancellable: ${taskId}`);
// Write as a system action — host will update inbound.db
writeMessageOut({
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'system',
content: JSON.stringify({ action: 'cancel_task', taskId }),
});
log(`cancel_task: ${taskId}`);
return ok(`Task cancelled: ${taskId}`);
return ok(`Task cancellation requested: ${taskId}`);
},
};
@@ -158,14 +162,14 @@ export const pauseTask: McpToolDefinition = {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
const result = getSessionDb()
.prepare("UPDATE messages_in SET status = 'paused', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status = 'pending'")
.run(taskId);
if (result.changes === 0) return err(`Task not found or not pausable: ${taskId}`);
writeMessageOut({
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'system',
content: JSON.stringify({ action: 'pause_task', taskId }),
});
log(`pause_task: ${taskId}`);
return ok(`Task paused: ${taskId}`);
return ok(`Task pause requested: ${taskId}`);
},
};
@@ -185,14 +189,14 @@ export const resumeTask: McpToolDefinition = {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
const result = getSessionDb()
.prepare("UPDATE messages_in SET status = 'pending', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status = 'paused'")
.run(taskId);
if (result.changes === 0) return err(`Task not found or not paused: ${taskId}`);
writeMessageOut({
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'system',
content: JSON.stringify({ action: 'resume_task', taskId }),
});
log(`resume_task: ${taskId}`);
return ok(`Task resumed: ${taskId}`);
return ok(`Task resume requested: ${taskId}`);
},
};