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,31 +1,86 @@
/**
* Two-DB connection layer.
*
* The session uses two SQLite files to eliminate write contention across
* the host-container mount boundary:
*
* inbound.db — host writes new messages here; container opens READ-ONLY
* outbound.db — container writes responses + acks here; host opens read-only
*
* Each file has exactly one writer, so no cross-process lock contention.
*/
import Database from 'better-sqlite3';
import fs from 'fs';
const SESSION_DB_PATH = '/workspace/session.db';
const DEFAULT_INBOUND_PATH = '/workspace/inbound.db';
const DEFAULT_OUTBOUND_PATH = '/workspace/outbound.db';
const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat';
let _db: Database.Database | null = null;
let _inbound: Database.Database | null = null;
let _outbound: Database.Database | null = null;
let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH;
export function getSessionDb(): Database.Database {
if (!_db) {
_db = new Database(process.env.SESSION_DB_PATH || SESSION_DB_PATH);
_db.pragma('journal_mode = DELETE');
_db.pragma('busy_timeout = 5000');
_db.pragma('foreign_keys = ON');
/** Inbound DB — container opens read-only (host is the sole writer). */
export function getInboundDb(): Database.Database {
if (!_inbound) {
const dbPath = process.env.SESSION_INBOUND_DB_PATH || DEFAULT_INBOUND_PATH;
_inbound = new Database(dbPath, { readonly: true });
_inbound.pragma('busy_timeout = 5000');
}
return _db;
return _inbound;
}
/** For tests — opens an in-memory DB with session schema. */
export function initTestSessionDb(): Database.Database {
_db = new Database(':memory:');
_db.pragma('foreign_keys = ON');
_db.exec(`
/** Outbound DB — container owns this file (sole writer). */
export function getOutboundDb(): Database.Database {
if (!_outbound) {
const dbPath = process.env.SESSION_OUTBOUND_DB_PATH || DEFAULT_OUTBOUND_PATH;
_outbound = new Database(dbPath);
_outbound.pragma('journal_mode = DELETE');
_outbound.pragma('busy_timeout = 5000');
_outbound.pragma('foreign_keys = ON');
}
return _outbound;
}
/**
* Touch the heartbeat file — replaces the old touchProcessing() DB writes.
* The host checks this file's mtime for stale container detection.
* A file touch is cheaper and avoids cross-boundary DB write contention.
*/
export function touchHeartbeat(): void {
const p = process.env.SESSION_HEARTBEAT_PATH || _heartbeatPath;
const now = new Date();
try {
fs.utimesSync(p, now, now);
} catch {
try {
fs.writeFileSync(p, '');
} catch {
// Silently ignore — parent dir may not exist (e.g., in-memory test DBs)
}
}
}
/**
* Clear stale processing_ack entries on container startup.
* If the previous container crashed, 'processing' entries are leftover.
* Clearing them lets the new container re-process those messages.
*/
export function clearStaleProcessingAcks(): void {
getOutboundDb().prepare("DELETE FROM processing_ack WHERE status = 'processing'").run();
}
/** For tests — creates in-memory DBs with the session schemas. */
export function initTestSessionDb(): { inbound: Database.Database; outbound: Database.Database } {
_inbound = new Database(':memory:');
_inbound.pragma('foreign_keys = ON');
_inbound.exec(`
CREATE TABLE messages_in (
id TEXT PRIMARY KEY,
seq INTEGER UNIQUE,
kind TEXT NOT NULL,
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending',
status_changed TEXT,
process_after TEXT,
recurrence TEXT,
tries INTEGER DEFAULT 0,
@@ -34,12 +89,20 @@ export function initTestSessionDb(): Database.Database {
thread_id TEXT,
content TEXT NOT NULL
);
CREATE TABLE delivered (
message_out_id TEXT PRIMARY KEY,
delivered_at TEXT NOT NULL
);
`);
_outbound = new Database(':memory:');
_outbound.pragma('foreign_keys = ON');
_outbound.exec(`
CREATE TABLE messages_out (
id TEXT PRIMARY KEY,
seq INTEGER UNIQUE,
in_reply_to TEXT,
timestamp TEXT NOT NULL,
delivered INTEGER DEFAULT 0,
deliver_after TEXT,
recurrence TEXT,
kind TEXT NOT NULL,
@@ -48,11 +111,27 @@ export function initTestSessionDb(): Database.Database {
thread_id TEXT,
content TEXT NOT NULL
);
CREATE TABLE processing_ack (
message_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
status_changed TEXT NOT NULL
);
`);
return _db;
return { inbound: _inbound, outbound: _outbound };
}
export function closeSessionDb(): void {
_db?.close();
_db = null;
_inbound?.close();
_inbound = null;
_outbound?.close();
_outbound = null;
}
/**
* @deprecated Use getInboundDb() / getOutboundDb() instead.
* Kept for backward compatibility during migration.
*/
export function getSessionDb(): Database.Database {
return getInboundDb();
}

View File

@@ -1,5 +1,13 @@
export { getSessionDb, initTestSessionDb, closeSessionDb } from './connection.js';
export {
getInboundDb,
getOutboundDb,
getSessionDb,
initTestSessionDb,
closeSessionDb,
touchHeartbeat,
clearStaleProcessingAcks,
} from './connection.js';
export { getPendingMessages, markProcessing, markCompleted, markFailed, getMessageIn, findQuestionResponse } from './messages-in.js';
export type { MessageInRow } from './messages-in.js';
export { writeMessageOut, getUndeliveredMessages, markDelivered } from './messages-out.js';
export { writeMessageOut, getUndeliveredMessages } from './messages-out.js';
export type { MessageOutRow, WriteMessageOut } from './messages-out.js';

View File

@@ -1,4 +1,13 @@
import { getSessionDb } from './connection.js';
/**
* Inbound message operations (container side).
*
* Reads from inbound.db (host-owned, opened read-only).
* Writes processing status to processing_ack in outbound.db (container-owned).
*
* The container never writes to inbound.db — all status tracking goes through
* processing_ack. The host reads processing_ack to sync message lifecycle.
*/
import { getInboundDb, getOutboundDb } from './connection.js';
export interface MessageInRow {
id: string;
@@ -6,7 +15,6 @@ export interface MessageInRow {
kind: string;
timestamp: string;
status: string;
status_changed: string | null;
process_after: string | null;
recurrence: string | null;
tries: number;
@@ -16,9 +24,16 @@ export interface MessageInRow {
content: string;
}
/** Fetch all pending messages that are due for processing. */
/**
* Fetch pending messages that are due for processing.
* Reads from inbound.db (read-only), filters against processing_ack in outbound.db
* to skip messages already picked up by this or a previous container run.
*/
export function getPendingMessages(): MessageInRow[] {
return getSessionDb()
const inbound = getInboundDb();
const outbound = getOutboundDb();
const pending = inbound
.prepare(
`SELECT * FROM messages_in
WHERE status = 'pending'
@@ -26,49 +41,74 @@ export function getPendingMessages(): MessageInRow[] {
ORDER BY timestamp ASC`,
)
.all() as MessageInRow[];
if (pending.length === 0) return [];
// Filter out messages already acknowledged in outbound.db
const ackedIds = new Set(
(outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map(
(r) => r.message_id,
),
);
return pending.filter((m) => !ackedIds.has(m.id));
}
/** Mark messages as processing. */
/** Mark messages as processing — writes to processing_ack in outbound.db. */
export function markProcessing(ids: string[]): void {
if (ids.length === 0) return;
const db = getSessionDb();
const stmt = db.prepare("UPDATE messages_in SET status = 'processing', status_changed = datetime('now'), tries = tries + 1 WHERE id = ?");
const db = getOutboundDb();
const stmt = db.prepare(
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', datetime('now'))",
);
db.transaction(() => {
for (const id of ids) stmt.run(id);
})();
}
/** Mark messages as completed. */
/** Mark messages as completed — updates processing_ack in outbound.db. */
export function markCompleted(ids: string[]): void {
if (ids.length === 0) return;
const db = getSessionDb();
const stmt = db.prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE id = ?");
const db = getOutboundDb();
const stmt = db.prepare(
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
);
db.transaction(() => {
for (const id of ids) stmt.run(id);
})();
}
/** Update status_changed on processing messages (heartbeat for host idle detection). */
export function touchProcessing(ids: string[]): void {
if (ids.length === 0) return;
const db = getSessionDb();
const stmt = db.prepare("UPDATE messages_in SET status_changed = datetime('now') WHERE id = ? AND status = 'processing'");
for (const id of ids) stmt.run(id);
}
/** Mark a single message as failed. */
/** Mark a single message as failed — writes to processing_ack in outbound.db. */
export function markFailed(id: string): void {
getSessionDb().prepare("UPDATE messages_in SET status = 'failed', status_changed = datetime('now') WHERE id = ?").run(id);
getOutboundDb()
.prepare(
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'failed', datetime('now'))",
)
.run(id);
}
/** Get a message by ID. */
/** Get a message by ID (read from inbound.db). */
export function getMessageIn(id: string): MessageInRow | undefined {
return getSessionDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined;
return getInboundDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined;
}
/** Find a pending response to a question (by questionId in content). */
/**
* Find a pending response to a question (by questionId in content).
* Reads from inbound.db, checks processing_ack to skip already-handled responses.
*/
export function findQuestionResponse(questionId: string): MessageInRow | undefined {
return getSessionDb()
const inbound = getInboundDb();
const outbound = getOutboundDb();
const response = inbound
.prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?")
.get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined;
if (!response) return undefined;
// Check it hasn't been acked already
const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id);
if (acked) return undefined;
return response;
}

View File

@@ -1,11 +1,16 @@
import { getSessionDb } from './connection.js';
/**
* Outbound message operations (container side).
*
* Writes to outbound.db (container-owned).
* The host polls this DB (read-only) for undelivered messages.
*/
import { getInboundDb, getOutboundDb } from './connection.js';
export interface MessageOutRow {
id: string;
seq: number | null;
in_reply_to: string | null;
timestamp: string;
delivered: number;
deliver_after: string | null;
recurrence: string | null;
kind: string;
@@ -27,59 +32,63 @@ export interface WriteMessageOut {
content: string;
}
/** Write a new outbound message, auto-assigning a seq number. */
/**
* Write a new outbound message, auto-assigning an odd seq number.
* Container uses odd seq (1, 3, 5...), host uses even (2, 4, 6...) —
* this prevents seq collisions without cross-DB coordination.
*/
export function writeMessageOut(msg: WriteMessageOut): number {
const db = getSessionDb();
const nextSeq = (
db
.prepare(
`SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM (
SELECT seq FROM messages_in WHERE seq IS NOT NULL
UNION ALL
SELECT seq FROM messages_out WHERE seq IS NOT NULL
)`,
)
.get() as { next: number }
).next;
const outbound = getOutboundDb();
const inbound = getInboundDb();
db.prepare(
`INSERT INTO messages_out (id, seq, in_reply_to, timestamp, delivered, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content)
VALUES (@id, @seq, @in_reply_to, datetime('now'), 0, @deliver_after, @recurrence, @kind, @platform_id, @channel_type, @thread_id, @content)`,
).run({
in_reply_to: null,
deliver_after: null,
recurrence: null,
platform_id: null,
channel_type: null,
thread_id: null,
...msg,
seq: nextSeq,
});
// Read max seq from both DBs to maintain global ordering.
// Safe: each side only reads the other DB, never writes to it.
const maxOut = (outbound.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m;
const maxIn = (inbound.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
const max = Math.max(maxOut, maxIn);
const nextSeq = max % 2 === 0 ? max + 1 : max + 2; // next odd
outbound
.prepare(
`INSERT INTO messages_out (id, seq, in_reply_to, timestamp, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content)
VALUES (@id, @seq, @in_reply_to, datetime('now'), @deliver_after, @recurrence, @kind, @platform_id, @channel_type, @thread_id, @content)`,
)
.run({
in_reply_to: null,
deliver_after: null,
recurrence: null,
platform_id: null,
channel_type: null,
thread_id: null,
...msg,
seq: nextSeq,
});
return nextSeq;
}
/** Look up a message's platform ID by seq number. */
/**
* Look up a message's platform ID by seq number.
* Searches both inbound and outbound DBs since seq spans both.
*/
export function getMessageIdBySeq(seq: number): string | null {
const inRow = getSessionDb().prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as { id: string } | undefined;
const inRow = getInboundDb().prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as
| { id: string }
| undefined;
if (inRow) return inRow.id;
const outRow = getSessionDb().prepare('SELECT id FROM messages_out WHERE seq = ?').get(seq) as { id: string } | undefined;
const outRow = getOutboundDb().prepare('SELECT id FROM messages_out WHERE seq = ?').get(seq) as
| { id: string }
| undefined;
return outRow?.id ?? null;
}
/** Get undelivered messages (for host polling). */
/** Get undelivered messages (for host polling — reads from outbound.db). */
export function getUndeliveredMessages(): MessageOutRow[] {
return getSessionDb()
return getOutboundDb()
.prepare(
`SELECT * FROM messages_out
WHERE delivered = 0
AND (deliver_after IS NULL OR deliver_after <= datetime('now'))
WHERE (deliver_after IS NULL OR deliver_after <= datetime('now'))
ORDER BY timestamp ASC`,
)
.all() as MessageOutRow[];
}
/** Mark a message as delivered. */
export function markDelivered(id: string): void {
getSessionDb().prepare('UPDATE messages_out SET delivered = 1 WHERE id = ?').run(id);
}