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 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 {
|
/** Inbound DB — container opens read-only (host is the sole writer). */
|
||||||
if (!_db) {
|
export function getInboundDb(): Database.Database {
|
||||||
_db = new Database(process.env.SESSION_DB_PATH || SESSION_DB_PATH);
|
if (!_inbound) {
|
||||||
_db.pragma('journal_mode = DELETE');
|
const dbPath = process.env.SESSION_INBOUND_DB_PATH || DEFAULT_INBOUND_PATH;
|
||||||
_db.pragma('busy_timeout = 5000');
|
_inbound = new Database(dbPath, { readonly: true });
|
||||||
_db.pragma('foreign_keys = ON');
|
_inbound.pragma('busy_timeout = 5000');
|
||||||
}
|
}
|
||||||
return _db;
|
return _inbound;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** For tests — opens an in-memory DB with session schema. */
|
/** Outbound DB — container owns this file (sole writer). */
|
||||||
export function initTestSessionDb(): Database.Database {
|
export function getOutboundDb(): Database.Database {
|
||||||
_db = new Database(':memory:');
|
if (!_outbound) {
|
||||||
_db.pragma('foreign_keys = ON');
|
const dbPath = process.env.SESSION_OUTBOUND_DB_PATH || DEFAULT_OUTBOUND_PATH;
|
||||||
_db.exec(`
|
_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 (
|
CREATE TABLE messages_in (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
seq INTEGER UNIQUE,
|
seq INTEGER UNIQUE,
|
||||||
kind TEXT NOT NULL,
|
kind TEXT NOT NULL,
|
||||||
timestamp TEXT NOT NULL,
|
timestamp TEXT NOT NULL,
|
||||||
status TEXT DEFAULT 'pending',
|
status TEXT DEFAULT 'pending',
|
||||||
status_changed TEXT,
|
|
||||||
process_after TEXT,
|
process_after TEXT,
|
||||||
recurrence TEXT,
|
recurrence TEXT,
|
||||||
tries INTEGER DEFAULT 0,
|
tries INTEGER DEFAULT 0,
|
||||||
@@ -34,12 +89,20 @@ export function initTestSessionDb(): Database.Database {
|
|||||||
thread_id TEXT,
|
thread_id TEXT,
|
||||||
content TEXT NOT NULL
|
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 (
|
CREATE TABLE messages_out (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
seq INTEGER UNIQUE,
|
seq INTEGER UNIQUE,
|
||||||
in_reply_to TEXT,
|
in_reply_to TEXT,
|
||||||
timestamp TEXT NOT NULL,
|
timestamp TEXT NOT NULL,
|
||||||
delivered INTEGER DEFAULT 0,
|
|
||||||
deliver_after TEXT,
|
deliver_after TEXT,
|
||||||
recurrence TEXT,
|
recurrence TEXT,
|
||||||
kind TEXT NOT NULL,
|
kind TEXT NOT NULL,
|
||||||
@@ -48,11 +111,27 @@ export function initTestSessionDb(): Database.Database {
|
|||||||
thread_id TEXT,
|
thread_id TEXT,
|
||||||
content TEXT NOT NULL
|
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 {
|
export function closeSessionDb(): void {
|
||||||
_db?.close();
|
_inbound?.close();
|
||||||
_db = null;
|
_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 { getPendingMessages, markProcessing, markCompleted, markFailed, getMessageIn, findQuestionResponse } from './messages-in.js';
|
||||||
export type { MessageInRow } 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';
|
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 {
|
export interface MessageInRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -6,7 +15,6 @@ export interface MessageInRow {
|
|||||||
kind: string;
|
kind: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
status: string;
|
status: string;
|
||||||
status_changed: string | null;
|
|
||||||
process_after: string | null;
|
process_after: string | null;
|
||||||
recurrence: string | null;
|
recurrence: string | null;
|
||||||
tries: number;
|
tries: number;
|
||||||
@@ -16,9 +24,16 @@ export interface MessageInRow {
|
|||||||
content: string;
|
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[] {
|
export function getPendingMessages(): MessageInRow[] {
|
||||||
return getSessionDb()
|
const inbound = getInboundDb();
|
||||||
|
const outbound = getOutboundDb();
|
||||||
|
|
||||||
|
const pending = inbound
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM messages_in
|
`SELECT * FROM messages_in
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
@@ -26,49 +41,74 @@ export function getPendingMessages(): MessageInRow[] {
|
|||||||
ORDER BY timestamp ASC`,
|
ORDER BY timestamp ASC`,
|
||||||
)
|
)
|
||||||
.all() as MessageInRow[];
|
.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 {
|
export function markProcessing(ids: string[]): void {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
const db = getSessionDb();
|
const db = getOutboundDb();
|
||||||
const stmt = db.prepare("UPDATE messages_in SET status = 'processing', status_changed = datetime('now'), tries = tries + 1 WHERE id = ?");
|
const stmt = db.prepare(
|
||||||
|
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', datetime('now'))",
|
||||||
|
);
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (const id of ids) stmt.run(id);
|
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 {
|
export function markCompleted(ids: string[]): void {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
const db = getSessionDb();
|
const db = getOutboundDb();
|
||||||
const stmt = db.prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE id = ?");
|
const stmt = db.prepare(
|
||||||
|
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
|
||||||
|
);
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (const id of ids) stmt.run(id);
|
for (const id of ids) stmt.run(id);
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update status_changed on processing messages (heartbeat for host idle detection). */
|
/** Mark a single message as failed — writes to processing_ack in outbound.db. */
|
||||||
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. */
|
|
||||||
export function markFailed(id: string): void {
|
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 {
|
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 {
|
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 ?")
|
.prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?")
|
||||||
.get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined;
|
.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 {
|
export interface MessageOutRow {
|
||||||
id: string;
|
id: string;
|
||||||
seq: number | null;
|
seq: number | null;
|
||||||
in_reply_to: string | null;
|
in_reply_to: string | null;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
delivered: number;
|
|
||||||
deliver_after: string | null;
|
deliver_after: string | null;
|
||||||
recurrence: string | null;
|
recurrence: string | null;
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -27,59 +32,63 @@ export interface WriteMessageOut {
|
|||||||
content: string;
|
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 {
|
export function writeMessageOut(msg: WriteMessageOut): number {
|
||||||
const db = getSessionDb();
|
const outbound = getOutboundDb();
|
||||||
const nextSeq = (
|
const inbound = getInboundDb();
|
||||||
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;
|
|
||||||
|
|
||||||
db.prepare(
|
// Read max seq from both DBs to maintain global ordering.
|
||||||
`INSERT INTO messages_out (id, seq, in_reply_to, timestamp, delivered, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content)
|
// Safe: each side only reads the other DB, never writes to it.
|
||||||
VALUES (@id, @seq, @in_reply_to, datetime('now'), 0, @deliver_after, @recurrence, @kind, @platform_id, @channel_type, @thread_id, @content)`,
|
const maxOut = (outbound.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m;
|
||||||
).run({
|
const maxIn = (inbound.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
|
||||||
in_reply_to: null,
|
const max = Math.max(maxOut, maxIn);
|
||||||
deliver_after: null,
|
const nextSeq = max % 2 === 0 ? max + 1 : max + 2; // next odd
|
||||||
recurrence: null,
|
|
||||||
platform_id: null,
|
outbound
|
||||||
channel_type: null,
|
.prepare(
|
||||||
thread_id: null,
|
`INSERT INTO messages_out (id, seq, in_reply_to, timestamp, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content)
|
||||||
...msg,
|
VALUES (@id, @seq, @in_reply_to, datetime('now'), @deliver_after, @recurrence, @kind, @platform_id, @channel_type, @thread_id, @content)`,
|
||||||
seq: nextSeq,
|
)
|
||||||
});
|
.run({
|
||||||
|
in_reply_to: null,
|
||||||
|
deliver_after: null,
|
||||||
|
recurrence: null,
|
||||||
|
platform_id: null,
|
||||||
|
channel_type: null,
|
||||||
|
thread_id: null,
|
||||||
|
...msg,
|
||||||
|
seq: nextSeq,
|
||||||
|
});
|
||||||
|
|
||||||
return 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 {
|
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;
|
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;
|
return outRow?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get undelivered messages (for host polling). */
|
/** Get undelivered messages (for host polling — reads from outbound.db). */
|
||||||
export function getUndeliveredMessages(): MessageOutRow[] {
|
export function getUndeliveredMessages(): MessageOutRow[] {
|
||||||
return getSessionDb()
|
return getOutboundDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM messages_out
|
`SELECT * FROM messages_out
|
||||||
WHERE delivered = 0
|
WHERE (deliver_after IS NULL OR deliver_after <= datetime('now'))
|
||||||
AND (deliver_after IS NULL OR deliver_after <= datetime('now'))
|
|
||||||
ORDER BY timestamp ASC`,
|
ORDER BY timestamp ASC`,
|
||||||
)
|
)
|
||||||
.all() as MessageOutRow[];
|
.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.
|
* No stdin, no stdout markers, no IPC files.
|
||||||
*
|
*
|
||||||
* Config:
|
* 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)
|
* - AGENT_PROVIDER: 'claude' | 'mock' (default: claude)
|
||||||
* - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving
|
* - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving
|
||||||
* - NANOCLAW_ADMIN_USER_ID: admin user ID for permission checks
|
* - NANOCLAW_ADMIN_USER_ID: admin user ID for permission checks
|
||||||
*
|
*
|
||||||
* Mount structure:
|
* Mount structure:
|
||||||
* /workspace/
|
* /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
|
* outbox/ ← outbound files
|
||||||
* agent/ ← agent group folder (CLAUDE.md, skills, working files)
|
* agent/ ← agent group folder (CLAUDE.md, skills, working files)
|
||||||
* .claude/ ← Claude SDK session data
|
* .claude/ ← Claude SDK session data
|
||||||
@@ -80,7 +84,9 @@ async function main(): Promise<void> {
|
|||||||
command: 'node',
|
command: 'node',
|
||||||
args: [mcpServerPath],
|
args: [mcpServerPath],
|
||||||
env: {
|
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 { 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 { getUndeliveredMessages } from './db/messages-out.js';
|
||||||
import { getPendingMessages } from './db/messages-in.js';
|
import { getPendingMessages } from './db/messages-in.js';
|
||||||
import { MockProvider } from './providers/mock.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 }) {
|
function insertMessage(id: string, content: object, opts?: { platformId?: string; channelType?: string; threadId?: string }) {
|
||||||
getSessionDb()
|
getInboundDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content)
|
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content)
|
||||||
VALUES (?, 'chat', datetime('now'), 'pending', ?, ?, ?, ?)`,
|
VALUES (?, 'chat', datetime('now'), 'pending', ?, ?, ?, ?)`,
|
||||||
@@ -25,20 +25,16 @@ function insertMessage(id: string, content: object, opts?: { platformId?: string
|
|||||||
|
|
||||||
describe('poll loop integration', () => {
|
describe('poll loop integration', () => {
|
||||||
it('should pick up a message, process it, and write a response', async () => {
|
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' });
|
insertMessage('m1', { sender: 'Alice', text: 'What is the meaning of life?' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-1' });
|
||||||
|
|
||||||
const provider = new MockProvider(() => '42');
|
const provider = new MockProvider(() => '42');
|
||||||
|
|
||||||
// Run the poll loop in background, abort after it processes
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||||
|
|
||||||
// Wait for processing
|
|
||||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||||
controller.abort();
|
controller.abort();
|
||||||
|
|
||||||
// Verify
|
|
||||||
const out = getUndeliveredMessages();
|
const out = getUndeliveredMessages();
|
||||||
expect(out).toHaveLength(1);
|
expect(out).toHaveLength(1);
|
||||||
expect(JSON.parse(out[0].content).text).toBe('42');
|
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].thread_id).toBe('thread-1');
|
||||||
expect(out[0].in_reply_to).toBe('m1');
|
expect(out[0].in_reply_to).toBe('m1');
|
||||||
|
|
||||||
// Input message should be completed
|
// Input message should be acked (not pending)
|
||||||
const pending = getPendingMessages();
|
const pending = getPendingMessages();
|
||||||
expect(pending).toHaveLength(0);
|
expect(pending).toHaveLength(0);
|
||||||
|
|
||||||
await loopPromise.catch(() => {}); // swallow abort
|
await loopPromise.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should process multiple messages in a batch', async () => {
|
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
|
* 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.
|
* 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 { writeMessageOut } from '../db/messages-out.js';
|
||||||
import type { McpToolDefinition } from './types.js';
|
import type { McpToolDefinition } from './types.js';
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ export const askUserQuestion: McpToolDefinition = {
|
|||||||
const questionId = generateId();
|
const questionId = generateId();
|
||||||
const r = routing();
|
const r = routing();
|
||||||
|
|
||||||
// Write question card to messages_out
|
// Write question card to outbound.db
|
||||||
writeMessageOut({
|
writeMessageOut({
|
||||||
id: questionId,
|
id: questionId,
|
||||||
kind: 'chat-sdk',
|
kind: 'chat-sdk',
|
||||||
@@ -81,19 +81,15 @@ export const askUserQuestion: McpToolDefinition = {
|
|||||||
|
|
||||||
log(`ask_user_question: ${questionId} → "${question}" [${options.join(', ')}]`);
|
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;
|
const deadline = Date.now() + timeout;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const response = getSessionDb()
|
const response = findQuestionResponse(questionId);
|
||||||
.prepare("SELECT content FROM messages_in WHERE kind = 'system' AND content LIKE ? AND status = 'pending' LIMIT 1")
|
|
||||||
.get(`%"questionId":"${questionId}"%`) as { content: string } | undefined;
|
|
||||||
|
|
||||||
if (response) {
|
if (response) {
|
||||||
const parsed = JSON.parse(response.content);
|
const parsed = JSON.parse(response.content);
|
||||||
// Mark the response as completed so the poll loop doesn't pick it up
|
// Mark the response as completed via processing_ack (outbound.db)
|
||||||
getSessionDb()
|
markCompleted([response.id]);
|
||||||
.prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE kind = 'system' AND content LIKE ?")
|
|
||||||
.run(`%"questionId":"${questionId}"%`);
|
|
||||||
|
|
||||||
log(`ask_user_question response: ${questionId} → ${parsed.selectedOption}`);
|
log(`ask_user_question response: ${questionId} → ${parsed.selectedOption}`);
|
||||||
return ok(parsed.selectedOption);
|
return ok(parsed.selectedOption);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Scheduling MCP tools: schedule_task, list_tasks, cancel_task, pause_task, resume_task.
|
* 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.
|
* With the two-DB split, the container cannot write to inbound.db (host-owned).
|
||||||
* The host sweep detects due tasks and wakes the container.
|
* 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';
|
import type { McpToolDefinition } from './types.js';
|
||||||
|
|
||||||
function log(msg: string): void {
|
function log(msg: string): void {
|
||||||
@@ -57,22 +59,22 @@ export const scheduleTask: McpToolDefinition = {
|
|||||||
const recurrence = (args.recurrence as string) || null;
|
const recurrence = (args.recurrence as string) || null;
|
||||||
const script = (args.script as string) || null;
|
const script = (args.script as string) || null;
|
||||||
|
|
||||||
const content = JSON.stringify({ prompt, script });
|
// Write as a system action — host will insert into inbound.db
|
||||||
|
writeMessageOut({
|
||||||
getSessionDb()
|
id,
|
||||||
.prepare(
|
kind: 'system',
|
||||||
`INSERT INTO messages_in (id, timestamp, status, status_changed, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content)
|
platform_id: r.platform_id,
|
||||||
VALUES (@id, datetime('now'), 'pending', datetime('now'), 0, @process_after, @recurrence, 'task', @platform_id, @channel_type, @thread_id, @content)`,
|
channel_type: r.channel_type,
|
||||||
)
|
thread_id: r.thread_id,
|
||||||
.run({
|
content: JSON.stringify({
|
||||||
id,
|
action: 'schedule_task',
|
||||||
process_after: processAfter,
|
taskId: id,
|
||||||
|
prompt,
|
||||||
|
script,
|
||||||
|
processAfter,
|
||||||
recurrence,
|
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})` : ''}`);
|
log(`schedule_task: ${id} at ${processAfter}${recurrence ? ` (recurring: ${recurrence})` : ''}`);
|
||||||
return ok(`Task scheduled (id: ${id}, runs at: ${processAfter}${recurrence ? `, recurrence: ${recurrence}` : ''})`);
|
return ok(`Task scheduled (id: ${id}, runs at: ${processAfter}${recurrence ? `, recurrence: ${recurrence}` : ''})`);
|
||||||
@@ -92,13 +94,14 @@ export const listTasks: McpToolDefinition = {
|
|||||||
},
|
},
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
const status = args.status as string | undefined;
|
const status = args.status as string | undefined;
|
||||||
|
const db = getInboundDb();
|
||||||
let rows;
|
let rows;
|
||||||
if (status) {
|
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")
|
.prepare("SELECT id, status, process_after, recurrence, content FROM messages_in WHERE kind = 'task' AND status = ? ORDER BY process_after ASC")
|
||||||
.all(status);
|
.all(status);
|
||||||
} else {
|
} 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")
|
.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();
|
.all();
|
||||||
}
|
}
|
||||||
@@ -131,14 +134,15 @@ export const cancelTask: McpToolDefinition = {
|
|||||||
const taskId = args.taskId as string;
|
const taskId = args.taskId as string;
|
||||||
if (!taskId) return err('taskId is required');
|
if (!taskId) return err('taskId is required');
|
||||||
|
|
||||||
const result = getSessionDb()
|
// Write as a system action — host will update inbound.db
|
||||||
.prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status IN ('pending', 'paused')")
|
writeMessageOut({
|
||||||
.run(taskId);
|
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
kind: 'system',
|
||||||
if (result.changes === 0) return err(`Task not found or not cancellable: ${taskId}`);
|
content: JSON.stringify({ action: 'cancel_task', taskId }),
|
||||||
|
});
|
||||||
|
|
||||||
log(`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;
|
const taskId = args.taskId as string;
|
||||||
if (!taskId) return err('taskId is required');
|
if (!taskId) return err('taskId is required');
|
||||||
|
|
||||||
const result = getSessionDb()
|
writeMessageOut({
|
||||||
.prepare("UPDATE messages_in SET status = 'paused', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status = 'pending'")
|
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
.run(taskId);
|
kind: 'system',
|
||||||
|
content: JSON.stringify({ action: 'pause_task', taskId }),
|
||||||
if (result.changes === 0) return err(`Task not found or not pausable: ${taskId}`);
|
});
|
||||||
|
|
||||||
log(`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;
|
const taskId = args.taskId as string;
|
||||||
if (!taskId) return err('taskId is required');
|
if (!taskId) return err('taskId is required');
|
||||||
|
|
||||||
const result = getSessionDb()
|
writeMessageOut({
|
||||||
.prepare("UPDATE messages_in SET status = 'pending', status_changed = datetime('now') WHERE id = ? AND kind = 'task' AND status = 'paused'")
|
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
.run(taskId);
|
kind: 'system',
|
||||||
|
content: JSON.stringify({ action: 'resume_task', taskId }),
|
||||||
if (result.changes === 0) return err(`Task not found or not paused: ${taskId}`);
|
});
|
||||||
|
|
||||||
log(`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 { 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 { getPendingMessages, markCompleted } from './db/messages-in.js';
|
||||||
import { getUndeliveredMessages } from './db/messages-out.js';
|
import { getUndeliveredMessages } from './db/messages-out.js';
|
||||||
import { formatMessages, extractRouting } from './formatter.js';
|
import { formatMessages, extractRouting } from './formatter.js';
|
||||||
@@ -15,7 +15,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string }) {
|
function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string }) {
|
||||||
getSessionDb()
|
getInboundDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, content)
|
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, content)
|
||||||
VALUES (?, ?, datetime('now'), 'pending', ?, ?)`,
|
VALUES (?, ?, datetime('now'), 'pending', ?, ?)`,
|
||||||
@@ -86,7 +86,7 @@ describe('formatter', () => {
|
|||||||
|
|
||||||
describe('routing', () => {
|
describe('routing', () => {
|
||||||
it('should extract routing from messages', () => {
|
it('should extract routing from messages', () => {
|
||||||
getSessionDb()
|
getInboundDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content)
|
`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"}')`,
|
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 }> = [];
|
const events: Array<{ type: string }> = [];
|
||||||
// End the stream after initial response
|
|
||||||
setTimeout(() => query.end(), 50);
|
setTimeout(() => query.end(), 50);
|
||||||
|
|
||||||
for await (const event of query.events) {
|
for await (const event of query.events) {
|
||||||
@@ -138,7 +137,6 @@ describe('mock provider', () => {
|
|||||||
|
|
||||||
const events: Array<{ type: string; text?: string }> = [];
|
const events: Array<{ type: string; text?: string }> = [];
|
||||||
|
|
||||||
// Push a follow-up after a short delay, then end
|
|
||||||
setTimeout(() => query.push('Second'), 30);
|
setTimeout(() => query.push('Second'), 30);
|
||||||
setTimeout(() => query.end(), 60);
|
setTimeout(() => query.end(), 60);
|
||||||
|
|
||||||
@@ -155,7 +153,7 @@ describe('mock provider', () => {
|
|||||||
|
|
||||||
describe('end-to-end with mock provider', () => {
|
describe('end-to-end with mock provider', () => {
|
||||||
it('should read messages_in, process with mock provider, write messages_out', async () => {
|
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?' });
|
insertMessage('m1', 'chat', { sender: 'User', text: 'What is 2+2?' });
|
||||||
|
|
||||||
// Read and process
|
// Read and process
|
||||||
@@ -198,11 +196,11 @@ describe('end-to-end with mock provider', () => {
|
|||||||
|
|
||||||
markCompleted(['m1']);
|
markCompleted(['m1']);
|
||||||
|
|
||||||
// Verify: message was processed
|
// Verify: message was processed (not pending, acked in processing_ack)
|
||||||
const processed = getPendingMessages();
|
const processed = getPendingMessages();
|
||||||
expect(processed).toHaveLength(0);
|
expect(processed).toHaveLength(0);
|
||||||
|
|
||||||
// Verify: response was written
|
// Verify: response was written to outbound DB
|
||||||
const outMessages = getUndeliveredMessages();
|
const outMessages = getUndeliveredMessages();
|
||||||
expect(outMessages).toHaveLength(1);
|
expect(outMessages).toHaveLength(1);
|
||||||
expect(JSON.parse(outMessages[0].content).text).toBe('The answer is 4');
|
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 { writeMessageOut } from './db/messages-out.js';
|
||||||
|
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||||
import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js';
|
import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js';
|
||||||
import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent } from './providers/types.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 sessionId: string | undefined;
|
||||||
let resumeAt: 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;
|
let pollCount = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
|
// 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) {
|
for await (const event of query.events) {
|
||||||
lastEventTime = Date.now();
|
lastEventTime = Date.now();
|
||||||
handleEvent(event, routing);
|
handleEvent(event, routing);
|
||||||
touchProcessing(processingIds);
|
touchHeartbeat();
|
||||||
|
|
||||||
if (event.type === 'init') {
|
if (event.type === 'init') {
|
||||||
querySessionId = event.sessionId;
|
querySessionId = event.sessionId;
|
||||||
|
|||||||
@@ -11,5 +11,5 @@
|
|||||||
"declaration": true
|
"declaration": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/v1/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
* Real end-to-end test of v2: host router → Docker container → agent-runner → delivery.
|
* Real end-to-end test of v2: host router → Docker container → agent-runner → delivery.
|
||||||
*
|
*
|
||||||
* 1. Init central DB with agent group + messaging group + wiring
|
* 1. Init central DB with agent group + messaging group + wiring
|
||||||
* 2. Route an inbound message (creates session, writes messages_in, spawns container)
|
* 2. Route an inbound message (creates session, writes inbound.db, spawns container)
|
||||||
* 3. Container runs v2 agent-runner, polls session DB, queries Claude
|
* 3. Container runs v2 agent-runner, polls inbound.db, queries Claude, writes outbound.db
|
||||||
* 4. Poll session DB for messages_out response
|
* 4. Poll outbound.db for messages_out response
|
||||||
*
|
*
|
||||||
* Usage: npx tsx scripts/test-v2-host.ts
|
* Usage: npx tsx scripts/test-v2-host.ts
|
||||||
*/
|
*/
|
||||||
@@ -71,7 +71,7 @@ console.log('\n=== Step 2: Route inbound message ===');
|
|||||||
|
|
||||||
import { routeInbound } from '../src/router.js';
|
import { routeInbound } from '../src/router.js';
|
||||||
import { findSession } from '../src/db/sessions.js';
|
import { findSession } from '../src/db/sessions.js';
|
||||||
import { sessionDbPath } from '../src/session-manager.js';
|
import { inboundDbPath, outboundDbPath } from '../src/session-manager.js';
|
||||||
|
|
||||||
await routeInbound({
|
await routeInbound({
|
||||||
channelType: 'test',
|
channelType: 'test',
|
||||||
@@ -96,8 +96,10 @@ if (!session) {
|
|||||||
console.log(`✓ Session: ${session.id}`);
|
console.log(`✓ Session: ${session.id}`);
|
||||||
console.log(`✓ Container status: ${session.container_status}`);
|
console.log(`✓ Container status: ${session.container_status}`);
|
||||||
|
|
||||||
const sessDbPath = sessionDbPath('ag-e2e', session.id);
|
const inDbPath = inboundDbPath('ag-e2e', session.id);
|
||||||
console.log(`✓ Session DB: ${sessDbPath}`);
|
const outDbPath = outboundDbPath('ag-e2e', session.id);
|
||||||
|
console.log(`✓ Inbound DB: ${inDbPath}`);
|
||||||
|
console.log(`✓ Outbound DB: ${outDbPath}`);
|
||||||
|
|
||||||
// --- Step 3: Wait for response ---
|
// --- Step 3: Wait for response ---
|
||||||
console.log('\n=== Step 3: Waiting for Claude response... ===');
|
console.log('\n=== Step 3: Waiting for Claude response... ===');
|
||||||
@@ -107,7 +109,7 @@ const TIMEOUT_MS = 120_000;
|
|||||||
|
|
||||||
const checkForResponse = (): boolean => {
|
const checkForResponse = (): boolean => {
|
||||||
try {
|
try {
|
||||||
const db = new Database(sessDbPath, { readonly: true });
|
const db = new Database(outDbPath, { readonly: true });
|
||||||
const out = db.prepare('SELECT * FROM messages_out').all() as Array<Record<string, unknown>>;
|
const out = db.prepare('SELECT * FROM messages_out').all() as Array<Record<string, unknown>>;
|
||||||
db.close();
|
db.close();
|
||||||
return out.length > 0;
|
return out.length > 0;
|
||||||
@@ -147,22 +149,36 @@ process.exit(0);
|
|||||||
|
|
||||||
function printState() {
|
function printState() {
|
||||||
try {
|
try {
|
||||||
const db = new Database(sessDbPath, { readonly: true });
|
const inDb = new Database(inDbPath, { readonly: true });
|
||||||
const inRows = db.prepare('SELECT * FROM messages_in').all() as Array<Record<string, unknown>>;
|
const inRows = inDb.prepare('SELECT * FROM messages_in').all() as Array<Record<string, unknown>>;
|
||||||
const outRows = db.prepare('SELECT * FROM messages_out').all() as Array<Record<string, unknown>>;
|
inDb.close();
|
||||||
db.close();
|
|
||||||
|
|
||||||
console.log('\nmessages_in:');
|
console.log('\nmessages_in (inbound.db):');
|
||||||
for (const r of inRows) {
|
for (const r of inRows) {
|
||||||
console.log(` [${r.id}] status=${r.status} kind=${r.kind}`);
|
console.log(` [${r.id}] status=${r.status} kind=${r.kind}`);
|
||||||
}
|
}
|
||||||
console.log('\nmessages_out:');
|
} catch (err) {
|
||||||
|
console.log(` (could not read inbound DB: ${err})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outDb = new Database(outDbPath, { readonly: true });
|
||||||
|
const outRows = outDb.prepare('SELECT * FROM messages_out').all() as Array<Record<string, unknown>>;
|
||||||
|
const ackRows = outDb.prepare('SELECT * FROM processing_ack').all() as Array<Record<string, unknown>>;
|
||||||
|
outDb.close();
|
||||||
|
|
||||||
|
console.log('\nmessages_out (outbound.db):');
|
||||||
for (const r of outRows) {
|
for (const r of outRows) {
|
||||||
const content = JSON.parse(r.content as string);
|
const content = JSON.parse(r.content as string);
|
||||||
console.log(` [${r.id}] kind=${r.kind}`);
|
console.log(` [${r.id}] kind=${r.kind}`);
|
||||||
console.log(` → ${content.text}`);
|
console.log(` → ${content.text}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('\nprocessing_ack (outbound.db):');
|
||||||
|
for (const r of ackRows) {
|
||||||
|
console.log(` [${r.message_id}] status=${r.status} changed=${r.status_changed}`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(` (could not read session DB: ${err})`);
|
console.log(` (could not read outbound DB: ${err})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ describe('channel + router integration', () => {
|
|||||||
it('should route inbound message from adapter to session DB', async () => {
|
it('should route inbound message from adapter to session DB', async () => {
|
||||||
const { routeInbound } = await import('../router.js');
|
const { routeInbound } = await import('../router.js');
|
||||||
const { findSession } = await import('../db/sessions.js');
|
const { findSession } = await import('../db/sessions.js');
|
||||||
const { sessionDbPath } = await import('../session-manager.js');
|
const { inboundDbPath } = await import('../session-manager.js');
|
||||||
|
|
||||||
// Simulate what the adapter bridge does: stringify content, call routeInbound
|
// Simulate what the adapter bridge does: stringify content, call routeInbound
|
||||||
const inboundContent = { sender: 'TestUser', senderId: 'u1', text: 'Hello from adapter', isFromMe: false };
|
const inboundContent = { sender: 'TestUser', senderId: 'u1', text: 'Hello from adapter', isFromMe: false };
|
||||||
@@ -183,7 +183,7 @@ describe('channel + router integration', () => {
|
|||||||
const session = findSession('mg-1', null);
|
const session = findSession('mg-1', null);
|
||||||
expect(session).toBeDefined();
|
expect(session).toBeDefined();
|
||||||
|
|
||||||
const dbPath = sessionDbPath('ag-1', session!.id);
|
const dbPath = inboundDbPath('ag-1', session!.id);
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ id: string; content: string }>;
|
const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ id: string; content: string }>;
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
markContainerIdle,
|
markContainerIdle,
|
||||||
markContainerRunning,
|
markContainerRunning,
|
||||||
markContainerStopped,
|
markContainerStopped,
|
||||||
sessionDbPath,
|
|
||||||
sessionDir,
|
sessionDir,
|
||||||
} from './session-manager.js';
|
} from './session-manager.js';
|
||||||
import type { AgentGroup, Session } from './types.js';
|
import type { AgentGroup, Session } from './types.js';
|
||||||
@@ -135,7 +134,7 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] {
|
|||||||
const sessDir = sessionDir(agentGroup.id, session.id);
|
const sessDir = sessionDir(agentGroup.id, session.id);
|
||||||
const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder);
|
const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder);
|
||||||
|
|
||||||
// Session folder at /workspace (contains session.db, outbox/, .claude/)
|
// Session folder at /workspace (contains inbound.db, outbound.db, outbox/, .claude/)
|
||||||
mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false });
|
mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false });
|
||||||
|
|
||||||
// Agent group folder at /workspace/agent
|
// Agent group folder at /workspace/agent
|
||||||
@@ -226,7 +225,10 @@ async function buildContainerArgs(
|
|||||||
// Environment
|
// Environment
|
||||||
args.push('-e', `TZ=${TIMEZONE}`);
|
args.push('-e', `TZ=${TIMEZONE}`);
|
||||||
args.push('-e', `AGENT_PROVIDER=${session.agent_provider || agentGroup.agent_provider || 'claude'}`);
|
args.push('-e', `AGENT_PROVIDER=${session.agent_provider || agentGroup.agent_provider || 'claude'}`);
|
||||||
args.push('-e', `SESSION_DB_PATH=/workspace/session.db`);
|
// Two-DB split: container reads inbound.db, writes outbound.db
|
||||||
|
args.push('-e', 'SESSION_INBOUND_DB_PATH=/workspace/inbound.db');
|
||||||
|
args.push('-e', 'SESSION_OUTBOUND_DB_PATH=/workspace/outbound.db');
|
||||||
|
args.push('-e', 'SESSION_HEARTBEAT_PATH=/workspace/.heartbeat');
|
||||||
|
|
||||||
// Pass admin user ID and assistant name from messaging group/agent group
|
// Pass admin user ID and assistant name from messaging group/agent group
|
||||||
if (session.messaging_group_id) {
|
if (session.messaging_group_id) {
|
||||||
@@ -239,10 +241,22 @@ async function buildContainerArgs(
|
|||||||
args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`);
|
args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OneCLI gateway
|
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
|
||||||
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
|
// are routed through the agent vault for credential injection.
|
||||||
if (onecliApplied) {
|
// Must ensureAgent first for non-admin groups, otherwise applyContainerConfig
|
||||||
log.debug('OneCLI gateway applied', { containerName });
|
// rejects the unknown agent identifier and returns false.
|
||||||
|
try {
|
||||||
|
if (agentIdentifier) {
|
||||||
|
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
|
||||||
|
}
|
||||||
|
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
|
||||||
|
if (onecliApplied) {
|
||||||
|
log.info('OneCLI gateway applied', { containerName });
|
||||||
|
} else {
|
||||||
|
log.warn('OneCLI gateway not applied — container will have no credentials', { containerName });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('OneCLI gateway error — container will have no credentials', { containerName, err });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Host gateway
|
// Host gateway
|
||||||
|
|||||||
@@ -69,16 +69,21 @@ CREATE TABLE pending_questions (
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session DB schema — created fresh by the host for each session.
|
* Session DB schemas — split into two files so each has exactly one writer.
|
||||||
|
* This eliminates SQLite write contention across the host-container mount boundary.
|
||||||
|
*
|
||||||
|
* inbound.db — host writes, container reads (read-only mount or open read-only)
|
||||||
|
* outbound.db — container writes, host reads (read-only open)
|
||||||
*/
|
*/
|
||||||
export const SESSION_SCHEMA = `
|
|
||||||
|
/** Host-owned: inbound messages + delivery tracking. */
|
||||||
|
export const INBOUND_SCHEMA = `
|
||||||
CREATE TABLE messages_in (
|
CREATE TABLE messages_in (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
seq INTEGER UNIQUE,
|
seq INTEGER UNIQUE,
|
||||||
kind TEXT NOT NULL,
|
kind TEXT NOT NULL,
|
||||||
timestamp TEXT NOT NULL,
|
timestamp TEXT NOT NULL,
|
||||||
status TEXT DEFAULT 'pending',
|
status TEXT DEFAULT 'pending',
|
||||||
status_changed TEXT,
|
|
||||||
process_after TEXT,
|
process_after TEXT,
|
||||||
recurrence TEXT,
|
recurrence TEXT,
|
||||||
tries INTEGER DEFAULT 0,
|
tries INTEGER DEFAULT 0,
|
||||||
@@ -88,12 +93,21 @@ CREATE TABLE messages_in (
|
|||||||
content TEXT NOT NULL
|
content TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Host tracks which messages_out IDs have been delivered.
|
||||||
|
-- Avoids writing to outbound.db (container-owned).
|
||||||
|
CREATE TABLE delivered (
|
||||||
|
message_out_id TEXT PRIMARY KEY,
|
||||||
|
delivered_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Container-owned: outbound messages + processing acknowledgments. */
|
||||||
|
export const OUTBOUND_SCHEMA = `
|
||||||
CREATE TABLE messages_out (
|
CREATE TABLE messages_out (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
seq INTEGER UNIQUE,
|
seq INTEGER UNIQUE,
|
||||||
in_reply_to TEXT,
|
in_reply_to TEXT,
|
||||||
timestamp TEXT NOT NULL,
|
timestamp TEXT NOT NULL,
|
||||||
delivered INTEGER DEFAULT 0,
|
|
||||||
deliver_after TEXT,
|
deliver_after TEXT,
|
||||||
recurrence TEXT,
|
recurrence TEXT,
|
||||||
kind TEXT NOT NULL,
|
kind TEXT NOT NULL,
|
||||||
@@ -102,4 +116,13 @@ CREATE TABLE messages_out (
|
|||||||
thread_id TEXT,
|
thread_id TEXT,
|
||||||
content TEXT NOT NULL
|
content TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Container tracks processing status here instead of updating messages_in.
|
||||||
|
-- Host reads this to know which messages have been processed.
|
||||||
|
-- On container startup, stale 'processing' entries are cleared (crash recovery).
|
||||||
|
CREATE TABLE processing_ack (
|
||||||
|
message_id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
status_changed TEXT NOT NULL
|
||||||
|
);
|
||||||
`;
|
`;
|
||||||
|
|||||||
126
src/delivery.ts
126
src/delivery.ts
@@ -1,6 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Outbound message delivery.
|
* Outbound message delivery.
|
||||||
* Polls active session DBs for undelivered messages_out, delivers through channel adapters.
|
* Polls session outbound DBs for undelivered messages, delivers through channel adapters.
|
||||||
|
*
|
||||||
|
* Two-DB architecture:
|
||||||
|
* - Reads messages_out from outbound.db (container-owned, opened read-only)
|
||||||
|
* - Tracks delivery in inbound.db's `delivered` table (host-owned)
|
||||||
|
* - Never writes to outbound.db — preserves single-writer-per-file invariant
|
||||||
*/
|
*/
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -9,7 +14,7 @@ import path from 'path';
|
|||||||
import { getRunningSessions, getActiveSessions, createPendingQuestion } from './db/sessions.js';
|
import { getRunningSessions, getActiveSessions, createPendingQuestion } from './db/sessions.js';
|
||||||
import { getAgentGroup } from './db/agent-groups.js';
|
import { getAgentGroup } from './db/agent-groups.js';
|
||||||
import { log } from './log.js';
|
import { log } from './log.js';
|
||||||
import { openSessionDb, sessionDir } from './session-manager.js';
|
import { openInboundDb, openOutboundDb, sessionDir, inboundDbPath } from './session-manager.js';
|
||||||
import { resetContainerIdleTimer } from './container-runner.js';
|
import { resetContainerIdleTimer } from './container-runner.js';
|
||||||
import type { OutboundFile } from './channels/adapter.js';
|
import type { OutboundFile } from './channels/adapter.js';
|
||||||
import type { Session } from './types.js';
|
import type { Session } from './types.js';
|
||||||
@@ -85,19 +90,21 @@ async function deliverSessionMessages(session: Session): Promise<void> {
|
|||||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||||
if (!agentGroup) return;
|
if (!agentGroup) return;
|
||||||
|
|
||||||
let db: Database.Database;
|
let outDb: Database.Database;
|
||||||
|
let inDb: Database.Database;
|
||||||
try {
|
try {
|
||||||
db = openSessionDb(agentGroup.id, session.id);
|
outDb = openOutboundDb(agentGroup.id, session.id);
|
||||||
|
inDb = openInboundDb(agentGroup.id, session.id);
|
||||||
} catch {
|
} catch {
|
||||||
return; // Session DB might not exist yet
|
return; // DBs might not exist yet
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const undelivered = db
|
// Read all due messages from outbound.db (read-only)
|
||||||
|
const allDue = outDb
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM messages_out
|
`SELECT * FROM messages_out
|
||||||
WHERE delivered = 0
|
WHERE (deliver_after IS NULL OR deliver_after <= datetime('now'))
|
||||||
AND (deliver_after IS NULL OR deliver_after <= datetime('now'))
|
|
||||||
ORDER BY timestamp ASC`,
|
ORDER BY timestamp ASC`,
|
||||||
)
|
)
|
||||||
.all() as Array<{
|
.all() as Array<{
|
||||||
@@ -109,19 +116,32 @@ async function deliverSessionMessages(session: Session): Promise<void> {
|
|||||||
content: string;
|
content: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
if (allDue.length === 0) return;
|
||||||
|
|
||||||
|
// Filter out already-delivered messages using inbound.db's delivered table
|
||||||
|
const deliveredIds = new Set(
|
||||||
|
(inDb.prepare('SELECT message_out_id FROM delivered').all() as Array<{ message_out_id: string }>).map(
|
||||||
|
(r) => r.message_out_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const undelivered = allDue.filter((m) => !deliveredIds.has(m.id));
|
||||||
if (undelivered.length === 0) return;
|
if (undelivered.length === 0) return;
|
||||||
|
|
||||||
for (const msg of undelivered) {
|
for (const msg of undelivered) {
|
||||||
try {
|
try {
|
||||||
await deliverMessage(msg, session);
|
await deliverMessage(msg, session, inDb);
|
||||||
db.prepare('UPDATE messages_out SET delivered = 1 WHERE id = ?').run(msg.id);
|
// Track delivery in inbound.db (host-owned) — not outbound.db
|
||||||
|
inDb.prepare("INSERT OR IGNORE INTO delivered (message_out_id, delivered_at) VALUES (?, datetime('now'))").run(
|
||||||
|
msg.id,
|
||||||
|
);
|
||||||
resetContainerIdleTimer(session.id);
|
resetContainerIdleTimer(session.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('Failed to deliver message', { messageId: msg.id, sessionId: session.id, err });
|
log.error('Failed to deliver message', { messageId: msg.id, sessionId: session.id, err });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
outDb.close();
|
||||||
|
inDb.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +155,7 @@ async function deliverMessage(
|
|||||||
content: string;
|
content: string;
|
||||||
},
|
},
|
||||||
session: Session,
|
session: Session,
|
||||||
|
inDb: Database.Database,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!deliveryAdapter) {
|
if (!deliveryAdapter) {
|
||||||
log.warn('No delivery adapter configured, dropping message', { id: msg.id });
|
log.warn('No delivery adapter configured, dropping message', { id: msg.id });
|
||||||
@@ -143,10 +164,9 @@ async function deliverMessage(
|
|||||||
|
|
||||||
const content = JSON.parse(msg.content);
|
const content = JSON.parse(msg.content);
|
||||||
|
|
||||||
// System actions — handle internally
|
// System actions — handle internally (schedule_task, cancel_task, etc.)
|
||||||
if (msg.kind === 'system') {
|
if (msg.kind === 'system') {
|
||||||
log.info('System action from agent', { sessionId: session.id, action: content.action });
|
await handleSystemAction(content, session, inDb);
|
||||||
// TODO: handle system actions (register_group, reset_session, etc.)
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +227,84 @@ async function deliverMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle system actions from the container agent.
|
||||||
|
* These are written to messages_out because the container can't write to inbound.db.
|
||||||
|
* The host applies them to inbound.db here.
|
||||||
|
*/
|
||||||
|
async function handleSystemAction(
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
session: Session,
|
||||||
|
inDb: Database.Database,
|
||||||
|
): Promise<void> {
|
||||||
|
const action = content.action as string;
|
||||||
|
log.info('System action from agent', { sessionId: session.id, action });
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'schedule_task': {
|
||||||
|
const taskId = content.taskId as string;
|
||||||
|
const prompt = content.prompt as string;
|
||||||
|
const script = content.script as string | null;
|
||||||
|
const processAfter = content.processAfter as string;
|
||||||
|
const recurrence = (content.recurrence as string) || null;
|
||||||
|
|
||||||
|
// Compute next even seq for host-owned inbound.db
|
||||||
|
const maxSeq = (
|
||||||
|
inDb.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);
|
||||||
|
|
||||||
|
inDb
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO messages_in (id, seq, timestamp, status, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content)
|
||||||
|
VALUES (@id, @seq, datetime('now'), 'pending', 0, @process_after, @recurrence, 'task', @platform_id, @channel_type, @thread_id, @content)`,
|
||||||
|
)
|
||||||
|
.run({
|
||||||
|
id: taskId,
|
||||||
|
seq: nextSeq,
|
||||||
|
process_after: processAfter,
|
||||||
|
recurrence,
|
||||||
|
platform_id: content.platformId ?? null,
|
||||||
|
channel_type: content.channelType ?? null,
|
||||||
|
thread_id: content.threadId ?? null,
|
||||||
|
content: JSON.stringify({ prompt, script }),
|
||||||
|
});
|
||||||
|
log.info('Scheduled task created', { taskId, processAfter, recurrence });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cancel_task': {
|
||||||
|
const taskId = content.taskId as string;
|
||||||
|
inDb
|
||||||
|
.prepare("UPDATE messages_in SET status = 'completed' WHERE id = ? AND kind = 'task' AND status IN ('pending', 'paused')")
|
||||||
|
.run(taskId);
|
||||||
|
log.info('Task cancelled', { taskId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'pause_task': {
|
||||||
|
const taskId = content.taskId as string;
|
||||||
|
inDb
|
||||||
|
.prepare("UPDATE messages_in SET status = 'paused' WHERE id = ? AND kind = 'task' AND status = 'pending'")
|
||||||
|
.run(taskId);
|
||||||
|
log.info('Task paused', { taskId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'resume_task': {
|
||||||
|
const taskId = content.taskId as string;
|
||||||
|
inDb
|
||||||
|
.prepare("UPDATE messages_in SET status = 'pending' WHERE id = ? AND kind = 'task' AND status = 'paused'")
|
||||||
|
.run(taskId);
|
||||||
|
log.info('Task resumed', { taskId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.warn('Unknown system action', { action });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function stopDeliveryPolls(): void {
|
export function stopDeliveryPolls(): void {
|
||||||
activePolling = false;
|
activePolling = false;
|
||||||
sweepPolling = false;
|
sweepPolling = false;
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
writeSessionMessage,
|
writeSessionMessage,
|
||||||
initSessionFolder,
|
initSessionFolder,
|
||||||
sessionDir,
|
sessionDir,
|
||||||
sessionDbPath,
|
inboundDbPath,
|
||||||
|
outboundDbPath,
|
||||||
sessionsBaseDir,
|
sessionsBaseDir,
|
||||||
} from './session-manager.js';
|
} from './session-manager.js';
|
||||||
import { getSession, findSession } from './db/sessions.js';
|
import { getSession, findSession } from './db/sessions.js';
|
||||||
@@ -84,22 +85,29 @@ describe('session manager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create session folder and DB', () => {
|
it('should create session folder and both DBs', () => {
|
||||||
initSessionFolder('ag-1', 'sess-test');
|
initSessionFolder('ag-1', 'sess-test');
|
||||||
const dir = sessionDir('ag-1', 'sess-test');
|
const dir = sessionDir('ag-1', 'sess-test');
|
||||||
expect(fs.existsSync(dir)).toBe(true);
|
expect(fs.existsSync(dir)).toBe(true);
|
||||||
expect(fs.existsSync(path.join(dir, 'outbox'))).toBe(true);
|
expect(fs.existsSync(path.join(dir, 'outbox'))).toBe(true);
|
||||||
|
|
||||||
const dbPath = sessionDbPath('ag-1', 'sess-test');
|
// Verify inbound.db
|
||||||
expect(fs.existsSync(dbPath)).toBe(true);
|
const inPath = inboundDbPath('ag-1', 'sess-test');
|
||||||
|
expect(fs.existsSync(inPath)).toBe(true);
|
||||||
|
const inDb = new Database(inPath);
|
||||||
|
const inTables = inDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
|
||||||
|
expect(inTables.map((t) => t.name)).toContain('messages_in');
|
||||||
|
expect(inTables.map((t) => t.name)).toContain('delivered');
|
||||||
|
inDb.close();
|
||||||
|
|
||||||
// Verify session DB has the right tables
|
// Verify outbound.db
|
||||||
const db = new Database(dbPath);
|
const outPath = outboundDbPath('ag-1', 'sess-test');
|
||||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
|
expect(fs.existsSync(outPath)).toBe(true);
|
||||||
const tableNames = tables.map((t) => t.name);
|
const outDb = new Database(outPath);
|
||||||
expect(tableNames).toContain('messages_in');
|
const outTables = outDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
|
||||||
expect(tableNames).toContain('messages_out');
|
expect(outTables.map((t) => t.name)).toContain('messages_out');
|
||||||
db.close();
|
expect(outTables.map((t) => t.name)).toContain('processing_ack');
|
||||||
|
outDb.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should resolve to existing session (shared mode)', () => {
|
it('should resolve to existing session (shared mode)', () => {
|
||||||
@@ -124,7 +132,7 @@ describe('session manager', () => {
|
|||||||
expect(s2.id).toBe(s1.id);
|
expect(s2.id).toBe(s1.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should write message to session DB', () => {
|
it('should write message to inbound DB', () => {
|
||||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||||
|
|
||||||
writeSessionMessage('ag-1', session.id, {
|
writeSessionMessage('ag-1', session.id, {
|
||||||
@@ -137,8 +145,8 @@ describe('session manager', () => {
|
|||||||
content: JSON.stringify({ sender: 'User', text: 'Hello' }),
|
content: JSON.stringify({ sender: 'User', text: 'Hello' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read from the session DB
|
// Read from the inbound DB
|
||||||
const dbPath = sessionDbPath('ag-1', session.id);
|
const dbPath = inboundDbPath('ag-1', session.id);
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{
|
const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -223,8 +231,8 @@ describe('router', () => {
|
|||||||
const session = findSession('mg-1', null);
|
const session = findSession('mg-1', null);
|
||||||
expect(session).toBeDefined();
|
expect(session).toBeDefined();
|
||||||
|
|
||||||
// Verify message was written to session DB
|
// Verify message was written to inbound DB
|
||||||
const dbPath = sessionDbPath('ag-1', session!.id);
|
const dbPath = inboundDbPath('ag-1', session!.id);
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ id: string; content: string }>;
|
const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ id: string; content: string }>;
|
||||||
db.close();
|
db.close();
|
||||||
@@ -239,8 +247,6 @@ describe('router', () => {
|
|||||||
it('should auto-create messaging group for unknown platform', async () => {
|
it('should auto-create messaging group for unknown platform', async () => {
|
||||||
const { routeInbound } = await import('./router.js');
|
const { routeInbound } = await import('./router.js');
|
||||||
|
|
||||||
// This platform ID isn't registered — but since there's no agent configured for it,
|
|
||||||
// it should create the messaging group but not route (no agents configured)
|
|
||||||
const event: InboundEvent = {
|
const event: InboundEvent = {
|
||||||
channelType: 'slack',
|
channelType: 'slack',
|
||||||
platformId: 'C-NEW-CHANNEL',
|
platformId: 'C-NEW-CHANNEL',
|
||||||
@@ -255,7 +261,6 @@ describe('router', () => {
|
|||||||
|
|
||||||
await routeInbound(event);
|
await routeInbound(event);
|
||||||
|
|
||||||
// Messaging group should be created
|
|
||||||
const { getMessagingGroupByPlatform } = await import('./db/messaging-groups.js');
|
const { getMessagingGroupByPlatform } = await import('./db/messaging-groups.js');
|
||||||
const mg = getMessagingGroupByPlatform('slack', 'C-NEW-CHANNEL');
|
const mg = getMessagingGroupByPlatform('slack', 'C-NEW-CHANNEL');
|
||||||
expect(mg).toBeDefined();
|
expect(mg).toBeDefined();
|
||||||
@@ -285,7 +290,7 @@ describe('router', () => {
|
|||||||
|
|
||||||
// Both should be in the same session
|
// Both should be in the same session
|
||||||
const session = findSession('mg-1', null);
|
const session = findSession('mg-1', null);
|
||||||
const dbPath = sessionDbPath('ag-1', session!.id);
|
const dbPath = inboundDbPath('ag-1', session!.id);
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
const rows = db.prepare('SELECT * FROM messages_in ORDER BY timestamp').all();
|
const rows = db.prepare('SELECT * FROM messages_in ORDER BY timestamp').all();
|
||||||
db.close();
|
db.close();
|
||||||
@@ -295,7 +300,7 @@ describe('router', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('delivery', () => {
|
describe('delivery', () => {
|
||||||
it('should detect undelivered messages in session DB', () => {
|
it('should detect undelivered messages in outbound DB', () => {
|
||||||
createAgentGroup({
|
createAgentGroup({
|
||||||
id: 'ag-1',
|
id: 'ag-1',
|
||||||
name: 'Agent',
|
name: 'Agent',
|
||||||
@@ -317,16 +322,15 @@ describe('delivery', () => {
|
|||||||
|
|
||||||
const { session } = resolveSession('ag-1', 'mg-test', null, 'shared');
|
const { session } = resolveSession('ag-1', 'mg-test', null, 'shared');
|
||||||
|
|
||||||
// Write a response to the session DB (simulating what the agent-runner does)
|
// Write a response to the outbound DB (simulating what the agent-runner does)
|
||||||
const dbPath = sessionDbPath('ag-1', session.id);
|
const dbPath = outboundDbPath('ag-1', session.id);
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
db.pragma('journal_mode = WAL');
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO messages_out (id, timestamp, delivered, kind, platform_id, channel_type, content)
|
`INSERT INTO messages_out (id, timestamp, kind, platform_id, channel_type, content)
|
||||||
VALUES ('out-1', datetime('now'), 0, 'chat', 'chan-123', 'discord', ?)`,
|
VALUES ('out-1', datetime('now'), 'chat', 'chan-123', 'discord', ?)`,
|
||||||
).run(JSON.stringify({ text: 'Agent response' }));
|
).run(JSON.stringify({ text: 'Agent response' }));
|
||||||
|
|
||||||
const undelivered = db.prepare('SELECT * FROM messages_out WHERE delivered = 0').all() as Array<{
|
const undelivered = db.prepare('SELECT * FROM messages_out').all() as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Host sweep — periodic maintenance of all session DBs.
|
* Host sweep — periodic maintenance of all session DBs.
|
||||||
*
|
*
|
||||||
* - Wake containers for sessions with due messages (process_after)
|
* Two-DB architecture:
|
||||||
* - Detect stale processing messages (container crash) → reset with backoff
|
* - Reads processing_ack from outbound.db to sync message status
|
||||||
* - Insert next occurrence for recurring messages
|
* - Writes to inbound.db (host-owned) for status updates and recurrence
|
||||||
* - Kill idle containers past timeout
|
* - Uses heartbeat file mtime for stale container detection (not DB writes)
|
||||||
|
* - Never writes to outbound.db — preserves single-writer-per-file invariant
|
||||||
*/
|
*/
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -12,7 +13,7 @@ import fs from 'fs';
|
|||||||
import { getActiveSessions, updateSession } from './db/sessions.js';
|
import { getActiveSessions, updateSession } from './db/sessions.js';
|
||||||
import { getAgentGroup } from './db/agent-groups.js';
|
import { getAgentGroup } from './db/agent-groups.js';
|
||||||
import { log } from './log.js';
|
import { log } from './log.js';
|
||||||
import { openSessionDb, sessionDbPath } from './session-manager.js';
|
import { openInboundDb, openOutboundDb, inboundDbPath, outboundDbPath, heartbeatPath } from './session-manager.js';
|
||||||
import { wakeContainer, isContainerRunning } from './container-runner.js';
|
import { wakeContainer, isContainerRunning } from './container-runner.js';
|
||||||
import type { Session } from './types.js';
|
import type { Session } from './types.js';
|
||||||
|
|
||||||
@@ -52,21 +53,31 @@ async function sweepSession(session: Session): Promise<void> {
|
|||||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||||
if (!agentGroup) return;
|
if (!agentGroup) return;
|
||||||
|
|
||||||
const dbPath = sessionDbPath(agentGroup.id, session.id);
|
const inPath = inboundDbPath(agentGroup.id, session.id);
|
||||||
if (!fs.existsSync(dbPath)) return;
|
if (!fs.existsSync(inPath)) return;
|
||||||
|
|
||||||
let db: Database.Database;
|
let inDb: Database.Database;
|
||||||
|
let outDb: Database.Database | null = null;
|
||||||
try {
|
try {
|
||||||
db = new Database(dbPath);
|
inDb = openInboundDb(agentGroup.id, session.id);
|
||||||
db.pragma('journal_mode = DELETE');
|
|
||||||
db.pragma('busy_timeout = 5000');
|
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Check for due pending messages → wake container
|
outDb = openOutboundDb(agentGroup.id, session.id);
|
||||||
const dueMessages = db
|
} catch {
|
||||||
|
// outbound.db might not exist yet (container hasn't started)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Sync processing_ack → messages_in status
|
||||||
|
if (outDb) {
|
||||||
|
syncProcessingAcks(inDb, outDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for due pending messages → wake container
|
||||||
|
const dueMessages = inDb
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT COUNT(*) as count FROM messages_in
|
`SELECT COUNT(*) as count FROM messages_in
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
@@ -79,90 +90,134 @@ async function sweepSession(session: Session): Promise<void> {
|
|||||||
await wakeContainer(session);
|
await wakeContainer(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Detect stale processing messages
|
// 3. Detect stale containers via heartbeat file
|
||||||
const staleMessages = db
|
if (outDb) {
|
||||||
.prepare(
|
detectStaleContainers(inDb, outDb, session, agentGroup.id);
|
||||||
`SELECT id, tries FROM messages_in
|
|
||||||
WHERE status = 'processing'
|
|
||||||
AND status_changed < datetime('now', '-${Math.floor(STALE_THRESHOLD_MS / 1000)} seconds')`,
|
|
||||||
)
|
|
||||||
.all() as Array<{ id: string; tries: number }>;
|
|
||||||
|
|
||||||
for (const msg of staleMessages) {
|
|
||||||
if (msg.tries >= MAX_TRIES) {
|
|
||||||
db.prepare("UPDATE messages_in SET status = 'failed', status_changed = datetime('now') WHERE id = ?").run(
|
|
||||||
msg.id,
|
|
||||||
);
|
|
||||||
log.warn('Message marked as failed after max retries', { messageId: msg.id, sessionId: session.id });
|
|
||||||
} else {
|
|
||||||
const backoffMs = BACKOFF_BASE_MS * Math.pow(2, msg.tries);
|
|
||||||
const backoffSec = Math.floor(backoffMs / 1000);
|
|
||||||
db.prepare(
|
|
||||||
`UPDATE messages_in SET status = 'pending', status_changed = datetime('now'), process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`,
|
|
||||||
).run(msg.id);
|
|
||||||
log.info('Reset stale message with backoff', { messageId: msg.id, tries: msg.tries, backoffMs });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Handle recurrence for completed messages
|
// 4. Handle recurrence for completed messages
|
||||||
const completedRecurring = db
|
handleRecurrence(inDb, session);
|
||||||
.prepare("SELECT * FROM messages_in WHERE status = 'completed' AND recurrence IS NOT NULL")
|
|
||||||
.all() as Array<{
|
|
||||||
id: string;
|
|
||||||
kind: string;
|
|
||||||
content: string;
|
|
||||||
recurrence: string;
|
|
||||||
process_after: string | null;
|
|
||||||
platform_id: string | null;
|
|
||||||
channel_type: string | null;
|
|
||||||
thread_id: string | null;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
for (const msg of completedRecurring) {
|
|
||||||
try {
|
|
||||||
// Dynamic import to avoid loading cron-parser at module level
|
|
||||||
const { CronExpressionParser } = await import('cron-parser');
|
|
||||||
const interval = CronExpressionParser.parse(msg.recurrence);
|
|
||||||
const nextRun = interval.next().toISOString();
|
|
||||||
const newId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
|
|
||||||
// Compute next seq from both tables (same pattern as session-manager.ts)
|
|
||||||
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;
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content)
|
|
||||||
VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
newId,
|
|
||||||
nextSeq,
|
|
||||||
msg.kind,
|
|
||||||
nextRun,
|
|
||||||
msg.recurrence,
|
|
||||||
msg.platform_id,
|
|
||||||
msg.channel_type,
|
|
||||||
msg.thread_id,
|
|
||||||
msg.content,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove recurrence from the completed message so it doesn't spawn again
|
|
||||||
db.prepare('UPDATE messages_in SET recurrence = NULL WHERE id = ?').run(msg.id);
|
|
||||||
|
|
||||||
log.info('Inserted next recurrence', { originalId: msg.id, newId, nextRun });
|
|
||||||
} catch (err) {
|
|
||||||
log.error('Failed to compute next recurrence', { messageId: msg.id, recurrence: msg.recurrence, err });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
inDb.close();
|
||||||
|
outDb?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync completed/failed processing_ack entries → messages_in.status.
|
||||||
|
* Only syncs terminal states — 'processing' is handled by stale detection.
|
||||||
|
*/
|
||||||
|
function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void {
|
||||||
|
const completed = outDb
|
||||||
|
.prepare("SELECT message_id FROM processing_ack WHERE status IN ('completed', 'failed')")
|
||||||
|
.all() as Array<{ message_id: string }>;
|
||||||
|
|
||||||
|
if (completed.length === 0) return;
|
||||||
|
|
||||||
|
// Batch-update messages_in status for completed/failed messages
|
||||||
|
const updateStmt = inDb.prepare(
|
||||||
|
"UPDATE messages_in SET status = 'completed' WHERE id = ? AND status != 'completed'",
|
||||||
|
);
|
||||||
|
inDb.transaction(() => {
|
||||||
|
for (const { message_id } of completed) {
|
||||||
|
updateStmt.run(message_id);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect stale containers using heartbeat file mtime.
|
||||||
|
* If the heartbeat is older than STALE_THRESHOLD and processing_ack has
|
||||||
|
* 'processing' entries, the container likely crashed — reset with backoff.
|
||||||
|
*/
|
||||||
|
function detectStaleContainers(
|
||||||
|
inDb: Database.Database,
|
||||||
|
outDb: Database.Database,
|
||||||
|
session: Session,
|
||||||
|
agentGroupId: string,
|
||||||
|
): void {
|
||||||
|
const hbPath = heartbeatPath(agentGroupId, session.id);
|
||||||
|
let heartbeatAge = Infinity;
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(hbPath);
|
||||||
|
heartbeatAge = Date.now() - stat.mtimeMs;
|
||||||
|
} catch {
|
||||||
|
// No heartbeat file — container may never have started, or it's very old
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heartbeatAge < STALE_THRESHOLD_MS) return; // Container is alive
|
||||||
|
|
||||||
|
// Heartbeat is stale — check for stuck processing entries
|
||||||
|
const processing = outDb
|
||||||
|
.prepare("SELECT message_id FROM processing_ack WHERE status = 'processing'")
|
||||||
|
.all() as Array<{ message_id: string }>;
|
||||||
|
|
||||||
|
if (processing.length === 0) return;
|
||||||
|
|
||||||
|
for (const { message_id } of processing) {
|
||||||
|
const msg = inDb
|
||||||
|
.prepare('SELECT id, tries FROM messages_in WHERE id = ? AND status = ?')
|
||||||
|
.get(message_id, 'pending') as { id: string; tries: number } | undefined;
|
||||||
|
|
||||||
|
if (!msg) continue;
|
||||||
|
|
||||||
|
if (msg.tries >= MAX_TRIES) {
|
||||||
|
inDb.prepare("UPDATE messages_in SET status = 'failed' WHERE id = ?").run(msg.id);
|
||||||
|
log.warn('Message marked as failed after max retries', { messageId: msg.id, sessionId: session.id });
|
||||||
|
} else {
|
||||||
|
const backoffMs = BACKOFF_BASE_MS * Math.pow(2, msg.tries);
|
||||||
|
const backoffSec = Math.floor(backoffMs / 1000);
|
||||||
|
inDb
|
||||||
|
.prepare(
|
||||||
|
`UPDATE messages_in SET tries = tries + 1, process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.run(msg.id);
|
||||||
|
log.info('Reset stale message with backoff', { messageId: msg.id, tries: msg.tries, backoffMs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert next occurrence for completed recurring messages. */
|
||||||
|
async function handleRecurrence(inDb: Database.Database, session: Session): Promise<void> {
|
||||||
|
const completedRecurring = inDb
|
||||||
|
.prepare("SELECT * FROM messages_in WHERE status = 'completed' AND recurrence IS NOT NULL")
|
||||||
|
.all() as Array<{
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
content: string;
|
||||||
|
recurrence: string;
|
||||||
|
process_after: string | null;
|
||||||
|
platform_id: string | null;
|
||||||
|
channel_type: string | null;
|
||||||
|
thread_id: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
for (const msg of completedRecurring) {
|
||||||
|
try {
|
||||||
|
const { CronExpressionParser } = await import('cron-parser');
|
||||||
|
const interval = CronExpressionParser.parse(msg.recurrence);
|
||||||
|
const nextRun = interval.next().toISOString();
|
||||||
|
const newId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
|
// Host uses even seq numbers
|
||||||
|
const maxSeq = (
|
||||||
|
inDb.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);
|
||||||
|
|
||||||
|
inDb
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content)
|
||||||
|
VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(newId, nextSeq, msg.kind, nextRun, msg.recurrence, msg.platform_id, msg.channel_type, msg.thread_id, msg.content);
|
||||||
|
|
||||||
|
// Remove recurrence from the completed message so it doesn't spawn again
|
||||||
|
inDb.prepare('UPDATE messages_in SET recurrence = NULL WHERE id = ?').run(msg.id);
|
||||||
|
|
||||||
|
log.info('Inserted next recurrence', { originalId: msg.id, newId, nextRun });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Failed to compute next recurrence', { messageId: msg.id, recurrence: msg.recurrence, err });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Session lifecycle management.
|
* Session lifecycle management.
|
||||||
* Creates session folders + DBs, writes messages, manages container status.
|
* 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 Database from 'better-sqlite3';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -9,7 +13,7 @@ import path from 'path';
|
|||||||
import { DATA_DIR } from './config.js';
|
import { DATA_DIR } from './config.js';
|
||||||
import { createSession, findSession, getSession, updateSession } from './db/sessions.js';
|
import { createSession, findSession, getSession, updateSession } from './db/sessions.js';
|
||||||
import { log } from './log.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';
|
import type { Session } from './types.js';
|
||||||
|
|
||||||
/** Root directory for all session data. */
|
/** Root directory for all session data. */
|
||||||
@@ -22,9 +26,27 @@ export function sessionDir(agentGroupId: string, sessionId: string): string {
|
|||||||
return path.join(sessionsBaseDir(), agentGroupId, sessionId);
|
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 {
|
export function sessionDbPath(agentGroupId: string, sessionId: string): string {
|
||||||
return path.join(sessionDir(agentGroupId, sessionId), 'session.db');
|
return inboundDbPath(agentGroupId, sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
@@ -41,8 +63,6 @@ export function resolveSession(
|
|||||||
threadId: string | null,
|
threadId: string | null,
|
||||||
sessionMode: 'shared' | 'per-thread',
|
sessionMode: 'shared' | 'per-thread',
|
||||||
): { session: Session; created: boolean } {
|
): { 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 lookupThreadId = sessionMode === 'shared' ? null : threadId;
|
||||||
const existing = findSession(messagingGroupId, lookupThreadId);
|
const existing = findSession(messagingGroupId, lookupThreadId);
|
||||||
|
|
||||||
@@ -50,7 +70,6 @@ export function resolveSession(
|
|||||||
return { session: existing, created: false };
|
return { session: existing, created: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new session
|
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
id,
|
id,
|
||||||
@@ -71,23 +90,32 @@ export function resolveSession(
|
|||||||
return { session, created: true };
|
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 {
|
export function initSessionFolder(agentGroupId: string, sessionId: string): void {
|
||||||
const dir = sessionDir(agentGroupId, sessionId);
|
const dir = sessionDir(agentGroupId, sessionId);
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
fs.mkdirSync(path.join(dir, 'outbox'), { recursive: true });
|
fs.mkdirSync(path.join(dir, 'outbox'), { recursive: true });
|
||||||
|
|
||||||
const dbPath = sessionDbPath(agentGroupId, sessionId);
|
const inPath = inboundDbPath(agentGroupId, sessionId);
|
||||||
if (!fs.existsSync(dbPath)) {
|
if (!fs.existsSync(inPath)) {
|
||||||
const db = new Database(dbPath);
|
const db = new Database(inPath);
|
||||||
db.pragma('journal_mode = DELETE');
|
db.pragma('journal_mode = DELETE');
|
||||||
db.exec(SESSION_SCHEMA);
|
db.exec(INBOUND_SCHEMA);
|
||||||
db.close();
|
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(
|
export function writeSessionMessage(
|
||||||
agentGroupId: string,
|
agentGroupId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -103,22 +131,19 @@ export function writeSessionMessage(
|
|||||||
recurrence?: string | null;
|
recurrence?: string | null;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
const dbPath = sessionDbPath(agentGroupId, sessionId);
|
const dbPath = inboundDbPath(agentGroupId, sessionId);
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
db.pragma('journal_mode = DELETE');
|
db.pragma('journal_mode = DELETE');
|
||||||
|
db.pragma('busy_timeout = 5000');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextSeq = (
|
// Host uses even seq numbers, container uses odd — prevents collisions
|
||||||
db
|
// across the two-DB boundary without cross-DB coordination.
|
||||||
.prepare(
|
const maxSeq = (
|
||||||
`SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM (
|
db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }
|
||||||
SELECT seq FROM messages_in WHERE seq IS NOT NULL
|
).m;
|
||||||
UNION ALL
|
const nextSeq = maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2); // next even
|
||||||
SELECT seq FROM messages_out WHERE seq IS NOT NULL
|
|
||||||
)`,
|
|
||||||
)
|
|
||||||
.get() as { next: number }
|
|
||||||
).next;
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence)
|
`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)`,
|
VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence)`,
|
||||||
@@ -138,18 +163,33 @@ export function writeSessionMessage(
|
|||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last_active
|
|
||||||
updateSession(sessionId, { last_active: new Date().toISOString() });
|
updateSession(sessionId, { last_active: new Date().toISOString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a session DB for reading (e.g., polling messages_out). */
|
/** Open the inbound DB for a session (host reads/writes). */
|
||||||
export function openSessionDb(agentGroupId: string, sessionId: string): Database.Database {
|
export function openInboundDb(agentGroupId: string, sessionId: string): Database.Database {
|
||||||
const dbPath = sessionDbPath(agentGroupId, sessionId);
|
const dbPath = inboundDbPath(agentGroupId, sessionId);
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
db.pragma('journal_mode = DELETE');
|
db.pragma('journal_mode = DELETE');
|
||||||
|
db.pragma('busy_timeout = 5000');
|
||||||
return db;
|
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. */
|
/** Mark a container as running for a session. */
|
||||||
export function markContainerRunning(sessionId: string): void {
|
export function markContainerRunning(sessionId: string): void {
|
||||||
updateSession(sessionId, { container_status: 'running', last_active: new Date().toISOString() });
|
updateSession(sessionId, { container_status: 'running', last_active: new Date().toISOString() });
|
||||||
|
|||||||
Reference in New Issue
Block a user