Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion runtime_runner/agent-runner/src/anthropic_direct_forum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,12 @@ export function buildMemoryMcpEnv(
const env: Record<string, string> = {
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
HOME: process.env.HOME || '/home/node',
KNOWLEDGE_DB_PATH: `/app/memory-db/${dbFile}`,
// dbFile may be '' when a forum snapshot failed to build host-side; the
// MCP server then runs without a knowledge store (degraded, never live).
KNOWLEDGE_DB_PATH: dbFile ? `/app/memory-db/${dbFile}` : '',
// Snapshot DBs never change while mounted: immutable=1 lets SQLite skip
// all locking/wal-index access (required for safety on macOS bind mounts).
KNOWLEDGE_DB_IMMUTABLE: containerInput.memoryMcp.dbIsSnapshot ? '1' : '',
MEMORY_SNAPSHOT_PATH: snapshotFile ? `/app/memory-db/${snapshotFile}` : '',
MCP_TOOLSET: 'forum',
FORUM_GENERATION: String(containerInput.memoryMcp.forumGeneration ?? 0),
Expand Down
7 changes: 6 additions & 1 deletion runtime_runner/agent-runner/src/memory_mcp_env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ export function buildOpenAIMemoryMcpEnv(
: '';
const memoryToolset = isOpenAIForumPhase(taskSource) ? 'forum' : 'task';
return {
KNOWLEDGE_DB_PATH: `/app/memory-db/${dbFile}`,
// dbFile may be '' when a forum snapshot failed to build host-side; the
// MCP server then runs without a knowledge store (degraded, never live).
KNOWLEDGE_DB_PATH: dbFile ? `/app/memory-db/${dbFile}` : '',
// Snapshot DBs never change while mounted: immutable=1 lets SQLite skip
// all locking/wal-index access (required for safety on macOS bind mounts).
KNOWLEDGE_DB_IMMUTABLE: containerInput.memoryMcp.dbIsSnapshot ? '1' : '',
MEMORY_SNAPSHOT_PATH: snapshotFile ? `/app/memory-db/${snapshotFile}` : '',
MCP_TOOLSET: memoryToolset,
FORUM_GENERATION: String(containerInput.memoryMcp.forumGeneration ?? 0),
Expand Down
9 changes: 8 additions & 1 deletion runtime_runner/agent-runner/src/query_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,14 @@ export function buildMcpServerConfig(
command: 'python3',
args: ['/app/memory/mcp_server.py'],
env: {
KNOWLEDGE_DB_PATH: `/app/memory-db/${dbFile}`,
// dbFile may be '' when a forum snapshot failed to build host-side;
// the MCP server then runs without a knowledge store (degraded, never
// live).
KNOWLEDGE_DB_PATH: dbFile ? `/app/memory-db/${dbFile}` : '',
// Snapshot DBs never change while mounted: immutable=1 lets SQLite
// skip all locking/wal-index access (required for safety on macOS
// bind mounts).
KNOWLEDGE_DB_IMMUTABLE: containerInput.memoryMcp.dbIsSnapshot ? '1' : '',
MEMORY_SNAPSHOT_PATH: snapshotFile ? `/app/memory-db/${snapshotFile}` : '',
MCP_TOOLSET: memoryToolset,
FORUM_GENERATION: String(containerInput.memoryMcp.forumGeneration ?? 0),
Expand Down
12 changes: 12 additions & 0 deletions runtime_runner/agent-runner/src/shared_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ export interface MemoryMcpConfig {
* directory mount (issue #1009).
*/
runtimeDbPath?: string;
/**
* True when dbPath points at a standalone point-in-time snapshot of the
* knowledge DB rather than the live file. Set by the Python container host
* for forum task sources: the live WAL trio (db + -wal + -shm) must never
* be bind-mounted into a container, because Docker Desktop's VirtioFS gives
* the container neither the host's POSIX advisory locks nor a coherent view
* of the -shm wal-index — even a read-only in-container SQLite connection
* writes to the wal-index and corrupts the host writer's database. When
* true, container_mounts.ts mounts dbPath as a single read-only file (no
* sidecars, no runtime-audit DB) and the MCP server opens it immutable.
*/
dbIsSnapshot?: boolean;
taskId?: string;
taskSource?: string;
forumGeneration?: number;
Expand Down
46 changes: 34 additions & 12 deletions runtime_runner/src/container_mounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,18 +373,40 @@ export function appendMemoryAndArcMounts(
// sidecars) rather than the whole per-experiment directory, so an agent
// can't enumerate sibling files that land in the same subdirectory.
if (forumWritesNeeded) {
// This branch only runs for forum containers, which need the DB
// mounted read-write (ForumBus.append / forum_signal_done INSERT),
// so the mounts are unconditionally read-write here.
addSqliteFileMounts(mounts, input.memoryMcp.dbPath, false, workspaceRuntime, 'Knowledge DB');
if (input.memoryMcp.runtimeDbPath) {
addSqliteFileMounts(
mounts,
input.memoryMcp.runtimeDbPath,
false,
workspaceRuntime,
'Runtime-audit DB',
);
if (input.memoryMcp.dbIsSnapshot) {
// dbPath is a standalone point-in-time snapshot (rollback-journal
// mode, no sidecars) cut by the Python container host. Mount it as
// a single read-only file and mount NOTHING live: bind-mounting a
// live WAL trio into the Docker VM corrupts the host DB on macOS
// (VirtioFS shares neither POSIX locks nor a coherent -shm mmap,
// and WAL readers write to the wal-index) — see
// MemoryMcpConfig.dbIsSnapshot. Forum WRITES don't need the DB:
// they flow through the forum_bus JSONL mount below and are
// drained into SQLite host-side. An empty/missing dbPath means
// snapshot creation failed — run without a knowledge DB rather
// than ever falling back to the live files.
if (input.memoryMcp.dbPath && fs.existsSync(input.memoryMcp.dbPath)) {
addSqliteFileMounts(mounts, input.memoryMcp.dbPath, true, workspaceRuntime, 'Knowledge DB snapshot');
} else {
logger.warn(
{ group: workspaceRuntime.name, dbPath: input.memoryMcp.dbPath },
'Knowledge DB snapshot missing — forum container runs without a knowledge DB',
);
}
} else {
// Legacy path (payloads without db_is_snapshot): live DB mounted
// read-write (ForumBus.append / forum_signal_done INSERT),
// so the mounts are unconditionally read-write here.
addSqliteFileMounts(mounts, input.memoryMcp.dbPath, false, workspaceRuntime, 'Knowledge DB');
if (input.memoryMcp.runtimeDbPath) {
addSqliteFileMounts(
mounts,
input.memoryMcp.runtimeDbPath,
false,
workspaceRuntime,
'Runtime-audit DB',
);
}
}
}
if (input.memoryMcp.snapshotPath) {
Expand Down
3 changes: 3 additions & 0 deletions runtime_runner/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,9 @@ async function main(): Promise<void> {
// bind-mount the runtime-audit DB individually; RUNTIME_DB_PATH
// (set below) already assumes it, so both must stay in sync.
runtimeDbPath: payload.runtime_audit?.db_path || undefined,
// Forum sources: dbPath is a standalone snapshot, and the live WAL
// trio must never be mounted (see MemoryMcpConfig.dbIsSnapshot).
dbIsSnapshot: Boolean(payload.knowledge.db_is_snapshot),
taskId: payload.task?.id || '',
taskSource,
forumGeneration: coerceOptionalNumber(taskMeta.forum_generation),
Expand Down
12 changes: 12 additions & 0 deletions runtime_runner/src/shared_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ export interface MemoryMcpConfig {
* directory mount (issue #1009).
*/
runtimeDbPath?: string;
/**
* True when dbPath points at a standalone point-in-time snapshot of the
* knowledge DB rather than the live file. Set by the Python container host
* for forum task sources: the live WAL trio (db + -wal + -shm) must never
* be bind-mounted into a container, because Docker Desktop's VirtioFS gives
* the container neither the host's POSIX advisory locks nor a coherent view
* of the -shm wal-index — even a read-only in-container SQLite connection
* writes to the wal-index and corrupts the host writer's database. When
* true, container_mounts.ts mounts dbPath as a single read-only file (no
* sidecars, no runtime-audit DB) and the MCP server opens it immutable.
*/
dbIsSnapshot?: boolean;
taskId?: string;
taskSource?: string;
forumGeneration?: number;
Expand Down
6 changes: 6 additions & 0 deletions runtime_runner/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export interface KsiPayload {
disable_memory_tools?: boolean;
forum_generation?: number;
experiment_name?: string;
/**
* True when db_path is a standalone snapshot cut for a forum container
* (may be "" when snapshot creation failed). The live WAL trio must not
* be mounted — see MemoryMcpConfig.dbIsSnapshot in shared_types.ts.
*/
db_is_snapshot?: boolean;
};
runtime_audit?: {
db_path: string;
Expand Down
40 changes: 40 additions & 0 deletions src/ksi/memory/_store_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,46 @@ def _locked_guard(
process_lock.release()


def snapshot_db_for_container_mount(src_path: str | Path, dest_path: str | Path) -> None:
"""Produce a standalone, self-contained copy of ``src_path`` at ``dest_path``.

Used by the container runtime to build the read-only knowledge DB that
forum containers mount INSTEAD of the live DB. The live WAL trio
(``db`` + ``-wal`` + ``-shm``) must never cross the Docker VM boundary: on
macOS the bind-mount filesystem (VirtioFS) provides neither cross-boundary
POSIX advisory locks nor coherent shared mmaps, so even a ``mode=ro``
SQLite connection inside a container silently fights the host writer over
the ``-shm`` wal-index (readers update read-marks and may rebuild the
wal-index) and corrupts the database — see
tests/memory/test_forum_container_db_snapshot.py.

``VACUUM INTO`` runs on its own read-only connection: it takes a consistent
WAL read snapshot of the source (safe alongside the store's writer thread,
same-host POSIX locking applies) and writes only to ``dest_path``. The
output is a compact single file in rollback-journal mode — no ``-wal`` /
``-shm`` sidecars — so a container can open it read-only (even
``immutable=1``) without ever touching shared WAL state.

Raises on failure (busy beyond the timeout, disk full); callers decide the
degrade path — they must NOT fall back to mounting the live DB.
"""
src = Path(src_path).resolve()
dest = Path(dest_path)
# VACUUM INTO refuses to overwrite; a stale file at dest (e.g. crashed
# prior run with the same name) must go first.
dest.unlink(missing_ok=True)
conn = sqlite3.connect(f"file:{src}?mode=ro", uri=True, timeout=30.0)
try:
conn.execute("PRAGMA busy_timeout=30000")
conn.execute("VACUUM INTO ?", (str(dest),))
except BaseException:
# Never leave a half-written snapshot behind for a container to mount.
conn.close()
dest.unlink(missing_ok=True)
raise
conn.close()


def _wal_checkpoint(
*,
read_only: bool,
Expand Down
13 changes: 13 additions & 0 deletions src/ksi/memory/knowledge_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ def __init__(
db_path: str,
*,
read_only: bool = False,
immutable: bool = False,
default_experiment: str = "default",
enable_vec: bool = False,
vec_dimensions: int = 768,
Expand All @@ -333,12 +334,24 @@ def __init__(
self._vec_enabled = False
self._vec_dimensions = vec_dimensions

# ``immutable`` promises SQLite the file cannot change while open, so
# it skips ALL locking and shared-memory (wal-index) access. That is
# only true for the standalone snapshot files the container runtime
# mounts for forum containers (see
# _store_common.snapshot_db_for_container_mount) — never for a live DB,
# where it would serve stale/torn reads. Requires read_only.
if immutable and not read_only:
raise ValueError("immutable=True requires read_only=True")
self._immutable = bool(immutable)

Path(db_path).parent.mkdir(parents=True, exist_ok=True)
KnowledgeStore._cleanup_stale_locks(Path(db_path).parent)
self._db_key = str(Path(db_path).resolve())

if self._read_only:
uri = f"file:{Path(db_path).resolve()}?mode=ro"
if self._immutable:
uri += "&immutable=1"
self._conn = sqlite3.connect(uri, uri=True, check_same_thread=False, timeout=30.0)
else:
self._conn = sqlite3.connect(db_path, check_same_thread=False, timeout=30.0)
Expand Down
12 changes: 11 additions & 1 deletion src/ksi/memory/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1327,13 +1327,23 @@ def main() -> None:
except Exception:
pass

# Initialize KnowledgeStore (authoritative swarm memory/state access)
# Initialize KnowledgeStore (authoritative swarm memory/state access).
# KNOWLEDGE_DB_IMMUTABLE=1 is set by the container runner when the mounted
# DB is a standalone point-in-time snapshot (forum containers — the live
# WAL trio never crosses the container boundary; see
# _store_common.snapshot_db_for_container_mount). immutable=1 makes SQLite
# skip locking and wal-index shared memory entirely, which is both correct
# (the snapshot never changes while mounted) and required for safety on
# macOS bind mounts, where even read-only WAL access writes to the shared
# -shm mapping.
knowledge_db_immutable = _env_flag("KNOWLEDGE_DB_IMMUTABLE", False)
knowledge_store: KnowledgeStore | None = None
if knowledge_db_path and Path(knowledge_db_path).exists():
try:
knowledge_store = KnowledgeStore(
knowledge_db_path,
read_only=True,
immutable=knowledge_db_immutable,
default_experiment=memory_experiment or "__mcp__",
enable_vec=enable_semantic,
)
Expand Down
Loading