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

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { initTestSessionDb, closeSessionDb, getSessionDb } from './db/connection.js';
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js';
import { getPendingMessages, markCompleted } from './db/messages-in.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { formatMessages, extractRouting } from './formatter.js';
@@ -15,7 +15,7 @@ afterEach(() => {
});
function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string }) {
getSessionDb()
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, content)
VALUES (?, ?, datetime('now'), 'pending', ?, ?)`,
@@ -86,7 +86,7 @@ describe('formatter', () => {
describe('routing', () => {
it('should extract routing from messages', () => {
getSessionDb()
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content)
VALUES ('m1', 'chat', datetime('now'), 'pending', 'chan-123', 'discord', 'thread-456', '{"text":"hi"}')`,
@@ -113,7 +113,6 @@ describe('mock provider', () => {
});
const events: Array<{ type: string }> = [];
// End the stream after initial response
setTimeout(() => query.end(), 50);
for await (const event of query.events) {
@@ -138,7 +137,6 @@ describe('mock provider', () => {
const events: Array<{ type: string; text?: string }> = [];
// Push a follow-up after a short delay, then end
setTimeout(() => query.push('Second'), 30);
setTimeout(() => query.end(), 60);
@@ -155,7 +153,7 @@ describe('mock provider', () => {
describe('end-to-end with mock provider', () => {
it('should read messages_in, process with mock provider, write messages_out', async () => {
// Insert a chat message
// Insert a chat message into inbound DB
insertMessage('m1', 'chat', { sender: 'User', text: 'What is 2+2?' });
// Read and process
@@ -198,11 +196,11 @@ describe('end-to-end with mock provider', () => {
markCompleted(['m1']);
// Verify: message was processed
// Verify: message was processed (not pending, acked in processing_ack)
const processed = getPendingMessages();
expect(processed).toHaveLength(0);
// Verify: response was written
// Verify: response was written to outbound DB
const outMessages = getUndeliveredMessages();
expect(outMessages).toHaveLength(1);
expect(JSON.parse(outMessages[0].content).text).toBe('The answer is 4');