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,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();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,18 @@
|
||||
* No stdin, no stdout markers, no IPC files.
|
||||
*
|
||||
* Config:
|
||||
* - SESSION_DB_PATH: path to session SQLite DB (default: /workspace/session.db)
|
||||
* - SESSION_INBOUND_DB_PATH: path to host-owned inbound DB (default: /workspace/inbound.db)
|
||||
* - SESSION_OUTBOUND_DB_PATH: path to container-owned outbound DB (default: /workspace/outbound.db)
|
||||
* - SESSION_HEARTBEAT_PATH: heartbeat file path (default: /workspace/.heartbeat)
|
||||
* - AGENT_PROVIDER: 'claude' | 'mock' (default: claude)
|
||||
* - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving
|
||||
* - NANOCLAW_ADMIN_USER_ID: admin user ID for permission checks
|
||||
*
|
||||
* Mount structure:
|
||||
* /workspace/
|
||||
* session.db ← session SQLite DB
|
||||
* inbound.db ← host-owned session DB (container reads only)
|
||||
* outbound.db ← container-owned session DB
|
||||
* .heartbeat ← container touches for liveness detection
|
||||
* outbox/ ← outbound files
|
||||
* agent/ ← agent group folder (CLAUDE.md, skills, working files)
|
||||
* .claude/ ← Claude SDK session data
|
||||
@@ -80,7 +84,9 @@ async function main(): Promise<void> {
|
||||
command: 'node',
|
||||
args: [mcpServerPath],
|
||||
env: {
|
||||
SESSION_DB_PATH: process.env.SESSION_DB_PATH || '/workspace/session.db',
|
||||
SESSION_INBOUND_DB_PATH: process.env.SESSION_INBOUND_DB_PATH || '/workspace/inbound.db',
|
||||
SESSION_OUTBOUND_DB_PATH: process.env.SESSION_OUTBOUND_DB_PATH || '/workspace/outbound.db',
|
||||
SESSION_HEARTBEAT_PATH: process.env.SESSION_HEARTBEAT_PATH || '/workspace/.heartbeat',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 { getUndeliveredMessages } from './db/messages-out.js';
|
||||
import { getPendingMessages } from './db/messages-in.js';
|
||||
import { MockProvider } from './providers/mock.js';
|
||||
@@ -15,7 +15,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
function insertMessage(id: string, content: object, opts?: { platformId?: string; channelType?: string; threadId?: string }) {
|
||||
getSessionDb()
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content)
|
||||
VALUES (?, 'chat', datetime('now'), 'pending', ?, ?, ?, ?)`,
|
||||
@@ -25,20 +25,16 @@ function insertMessage(id: string, content: object, opts?: { platformId?: string
|
||||
|
||||
describe('poll loop integration', () => {
|
||||
it('should pick up a message, process it, and write a response', async () => {
|
||||
// Insert a message before starting the loop
|
||||
insertMessage('m1', { sender: 'Alice', text: 'What is the meaning of life?' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-1' });
|
||||
|
||||
const provider = new MockProvider(() => '42');
|
||||
|
||||
// Run the poll loop in background, abort after it processes
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
// Wait for processing
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
// Verify
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toBe('42');
|
||||
@@ -47,11 +43,11 @@ describe('poll loop integration', () => {
|
||||
expect(out[0].thread_id).toBe('thread-1');
|
||||
expect(out[0].in_reply_to).toBe('m1');
|
||||
|
||||
// Input message should be completed
|
||||
// Input message should be acked (not pending)
|
||||
const pending = getPendingMessages();
|
||||
expect(pending).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {}); // swallow abort
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('should process multiple messages in a batch', async () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getPendingMessages, markProcessing, markCompleted, touchProcessing, type MessageInRow } from './db/messages-in.js';
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js';
|
||||
import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent } from './providers/types.js';
|
||||
|
||||
@@ -38,6 +39,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
let sessionId: string | undefined;
|
||||
let resumeAt: string | undefined;
|
||||
|
||||
// Clear leftover 'processing' acks from a previous crashed container.
|
||||
// This lets the new container re-process those messages.
|
||||
clearStaleProcessingAcks();
|
||||
|
||||
let pollCount = 0;
|
||||
while (true) {
|
||||
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
|
||||
@@ -260,7 +265,7 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config:
|
||||
for await (const event of query.events) {
|
||||
lastEventTime = Date.now();
|
||||
handleEvent(event, routing);
|
||||
touchProcessing(processingIds);
|
||||
touchHeartbeat();
|
||||
|
||||
if (event.type === 'init') {
|
||||
querySessionId = event.sessionId;
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/v1/**/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user