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:
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* Session lifecycle management.
|
||||
* Creates session folders + DBs, writes messages, manages container status.
|
||||
*
|
||||
* Two-DB architecture: each session has inbound.db (host-owned) and outbound.db
|
||||
* (container-owned). This eliminates SQLite write contention across the
|
||||
* host-container mount boundary — each file has exactly one writer.
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
@@ -9,7 +13,7 @@ import path from 'path';
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { createSession, findSession, getSession, updateSession } from './db/sessions.js';
|
||||
import { log } from './log.js';
|
||||
import { SESSION_SCHEMA } from './db/schema.js';
|
||||
import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from './db/schema.js';
|
||||
import type { Session } from './types.js';
|
||||
|
||||
/** Root directory for all session data. */
|
||||
@@ -22,9 +26,27 @@ export function sessionDir(agentGroupId: string, sessionId: string): string {
|
||||
return path.join(sessionsBaseDir(), agentGroupId, sessionId);
|
||||
}
|
||||
|
||||
/** Path to a session's SQLite DB. */
|
||||
/** Path to the host-owned inbound DB (messages_in + delivered). */
|
||||
export function inboundDbPath(agentGroupId: string, sessionId: string): string {
|
||||
return path.join(sessionDir(agentGroupId, sessionId), 'inbound.db');
|
||||
}
|
||||
|
||||
/** Path to the container-owned outbound DB (messages_out + processing_ack). */
|
||||
export function outboundDbPath(agentGroupId: string, sessionId: string): string {
|
||||
return path.join(sessionDir(agentGroupId, sessionId), 'outbound.db');
|
||||
}
|
||||
|
||||
/** Path to the container heartbeat file (touched instead of DB writes). */
|
||||
export function heartbeatPath(agentGroupId: string, sessionId: string): string {
|
||||
return path.join(sessionDir(agentGroupId, sessionId), '.heartbeat');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use inboundDbPath / outboundDbPath instead.
|
||||
* Kept temporarily for test compatibility during migration.
|
||||
*/
|
||||
export function sessionDbPath(agentGroupId: string, sessionId: string): string {
|
||||
return path.join(sessionDir(agentGroupId, sessionId), 'session.db');
|
||||
return inboundDbPath(agentGroupId, sessionId);
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
@@ -41,8 +63,6 @@ export function resolveSession(
|
||||
threadId: string | null,
|
||||
sessionMode: 'shared' | 'per-thread',
|
||||
): { session: Session; created: boolean } {
|
||||
// For shared mode, look for any active session with this messaging group (threadId ignored)
|
||||
// For per-thread mode, look for an active session with this specific thread
|
||||
const lookupThreadId = sessionMode === 'shared' ? null : threadId;
|
||||
const existing = findSession(messagingGroupId, lookupThreadId);
|
||||
|
||||
@@ -50,7 +70,6 @@ export function resolveSession(
|
||||
return { session: existing, created: false };
|
||||
}
|
||||
|
||||
// Create new session
|
||||
const id = generateId();
|
||||
const session: Session = {
|
||||
id,
|
||||
@@ -71,23 +90,32 @@ export function resolveSession(
|
||||
return { session, created: true };
|
||||
}
|
||||
|
||||
/** Create the session folder and initialize the session DB. */
|
||||
/** Create the session folder and initialize both DBs. */
|
||||
export function initSessionFolder(agentGroupId: string, sessionId: string): void {
|
||||
const dir = sessionDir(agentGroupId, sessionId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'outbox'), { recursive: true });
|
||||
|
||||
const dbPath = sessionDbPath(agentGroupId, sessionId);
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
const db = new Database(dbPath);
|
||||
const inPath = inboundDbPath(agentGroupId, sessionId);
|
||||
if (!fs.existsSync(inPath)) {
|
||||
const db = new Database(inPath);
|
||||
db.pragma('journal_mode = DELETE');
|
||||
db.exec(SESSION_SCHEMA);
|
||||
db.exec(INBOUND_SCHEMA);
|
||||
db.close();
|
||||
log.debug('Session DB created', { dbPath });
|
||||
log.debug('Inbound DB created', { dbPath: inPath });
|
||||
}
|
||||
|
||||
const outPath = outboundDbPath(agentGroupId, sessionId);
|
||||
if (!fs.existsSync(outPath)) {
|
||||
const db = new Database(outPath);
|
||||
db.pragma('journal_mode = DELETE');
|
||||
db.exec(OUTBOUND_SCHEMA);
|
||||
db.close();
|
||||
log.debug('Outbound DB created', { dbPath: outPath });
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a message to a session's messages_in table. */
|
||||
/** Write a message to a session's inbound DB (messages_in). Host-only. */
|
||||
export function writeSessionMessage(
|
||||
agentGroupId: string,
|
||||
sessionId: string,
|
||||
@@ -103,22 +131,19 @@ export function writeSessionMessage(
|
||||
recurrence?: string | null;
|
||||
},
|
||||
): void {
|
||||
const dbPath = sessionDbPath(agentGroupId, sessionId);
|
||||
const dbPath = inboundDbPath(agentGroupId, sessionId);
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = DELETE');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
|
||||
try {
|
||||
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;
|
||||
// Host uses even seq numbers, container uses odd — prevents collisions
|
||||
// across the two-DB boundary without cross-DB coordination.
|
||||
const maxSeq = (
|
||||
db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }
|
||||
).m;
|
||||
const nextSeq = maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2); // next even
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence)
|
||||
VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence)`,
|
||||
@@ -138,18 +163,33 @@ export function writeSessionMessage(
|
||||
db.close();
|
||||
}
|
||||
|
||||
// Update last_active
|
||||
updateSession(sessionId, { last_active: new Date().toISOString() });
|
||||
}
|
||||
|
||||
/** Open a session DB for reading (e.g., polling messages_out). */
|
||||
export function openSessionDb(agentGroupId: string, sessionId: string): Database.Database {
|
||||
const dbPath = sessionDbPath(agentGroupId, sessionId);
|
||||
/** Open the inbound DB for a session (host reads/writes). */
|
||||
export function openInboundDb(agentGroupId: string, sessionId: string): Database.Database {
|
||||
const dbPath = inboundDbPath(agentGroupId, sessionId);
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = DELETE');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Open the outbound DB for a session (host reads only). */
|
||||
export function openOutboundDb(agentGroupId: string, sessionId: string): Database.Database {
|
||||
const dbPath = outboundDbPath(agentGroupId, sessionId);
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
db.pragma('busy_timeout = 5000');
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use openInboundDb / openOutboundDb instead.
|
||||
*/
|
||||
export function openSessionDb(agentGroupId: string, sessionId: string): Database.Database {
|
||||
return openInboundDb(agentGroupId, sessionId);
|
||||
}
|
||||
|
||||
/** Mark a container as running for a session. */
|
||||
export function markContainerRunning(sessionId: string): void {
|
||||
updateSession(sessionId, { container_status: 'running', last_active: new Date().toISOString() });
|
||||
|
||||
Reference in New Issue
Block a user