diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 3ca44a8..871e43a 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -27,21 +27,29 @@ const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat'; let _inbound: Database | null = null; let _outbound: Database | null = null; let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH; +let _testMode = false; /** - * Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled. - * + * Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled. + * * Use this (not getInboundDb) for readers that need to see host-written rows * promptly — e.g. messages_in polling. Caller must .close() the returned * connection (try/finally). * * Needed for mounts where host writes don't reliably invalidate * SQLite's caches: virtiofs (Colima, Lima, Podman Machine, Apple - * Container), NFS. - * + * Container), NFS. + * * Cost is microseconds per query, so safe for universal use. */ export function openInboundDb(): Database { + // In test mode return a thin wrapper over the in-memory singleton. + // Callers do try/finally { db.close() } — the wrapper no-ops close() + // so the singleton survives for the rest of the test. + if (_testMode && _inbound) { + const db = _inbound; + return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database; + } const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); db.exec('PRAGMA busy_timeout = 5000'); db.exec('PRAGMA mmap_size = 0'); @@ -170,6 +178,7 @@ export function clearStaleProcessingAcks(): void { /** For tests — creates in-memory DBs with the session schemas. */ export function initTestSessionDb(): { inbound: Database; outbound: Database } { + _testMode = true; _inbound = new Database(':memory:'); _inbound.exec('PRAGMA foreign_keys = ON'); _inbound.exec(` @@ -246,6 +255,7 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } { export function closeSessionDb(): void { _inbound?.close(); _inbound = null; + _testMode = false; _outbound?.close(); _outbound = null; } diff --git a/migrate-v2.sh b/migrate-v2.sh index f06a548..2325edd 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -408,20 +408,12 @@ else fi done - # 2d. WhatsApp LID resolution. After whatsapp is installed (so Baileys - # is on disk) and auth files have been copied (so we can connect with - # the migrated identity), boot Baileys briefly to learn LID↔phone - # mappings during initial sync, then write paired LID-keyed - # messaging_groups. Best-effort: any failure degrades to runtime - # approval flow, which the WA adapter's isMention=true on DMs handles. - for ch in "${SELECTED_CHANNELS[@]}"; do - if [ "$ch" = "whatsapp" ]; then - run_step "2d-whatsapp-lids" \ - "Resolve WhatsApp LIDs for migrated DMs" \ - "setup/migrate-v2/whatsapp-resolve-lids.ts" - break - fi - done + # 2d. (Removed) WhatsApp LID resolution was previously needed because the + # v6 adapter couldn't reliably translate LID→phone JIDs, so the migration + # pre-created dual messaging_groups rows. With Baileys v7, the adapter + # resolves LIDs via extractAddressingContext + signalRepository.lidMapping + # on every inbound message, so dual rows are unnecessary and were causing + # split sessions. fi echo diff --git a/package.json b/package.json index f92ed88..35856b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.30", + "version": "2.0.31", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index d0bd6da..263081f 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 140k tokens, 70% of context window + + 141k tokens, 70% of context window @@ -15,8 +15,8 @@ tokens - - 140k + + 141k diff --git a/setup/migrate-v2/whatsapp-resolve-lids.ts b/setup/migrate-v2/whatsapp-resolve-lids.ts deleted file mode 100644 index 7a5eb8b..0000000 --- a/setup/migrate-v2/whatsapp-resolve-lids.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * migrate-v2 step: resolve WhatsApp LIDs for migrated DM messaging_groups. - * - * Why this exists - * ─────────────── - * v1 stored every WhatsApp DM as `@s.whatsapp.net`. v2's WA adapter - * sometimes resolves the chat to `@lid` instead — when WhatsApp - * delivers a message via the LID protocol and Baileys hasn't yet learned - * a LID→phone mapping for that contact (cold cache after migration). The - * router then can't find the phone-keyed messaging_group and silently - * drops the message at router.ts:184 — until the LID is learned (which - * happens lazily, message-by-message, via `chats.phoneNumberShare`). - * - * Baileys persists LID↔phone mappings to disk as - * `store/auth/lid-mapping-_reverse.json` (LID → phone) and - * `lid-mapping-.json` (phone → LID). v1 will already have populated - * these for every contact it talked to. This step parses the reverse - * files and writes paired LID-keyed `messaging_groups` + - * `messaging_group_agents` rows so both `@s.whatsapp.net` and - * `@lid` route to the same agent_group with the same engage rules. - * - * No Baileys boot, no network — pure filesystem read. If store/auth is - * missing or has no reverse mappings, exits 0 with a SKIPPED. Runtime - * fallback (WA adapter sets isMention=true on DMs → router auto-creates - * with `unknown_sender_policy=request_approval`) handles anything we - * miss. - * - * Usage: pnpm exec tsx setup/migrate-v2/whatsapp-resolve-lids.ts - */ -import fs from 'fs'; -import path from 'path'; - -import { DATA_DIR } from '../../src/config.js'; -import { initDb } from '../../src/db/connection.js'; -import { - createMessagingGroup, - createMessagingGroupAgent, - getMessagingGroupAgentByPair, - getMessagingGroupByPlatform, -} from '../../src/db/messaging-groups.js'; -import { runMigrations } from '../../src/db/migrations/index.js'; -import { generateId } from './shared.js'; - -interface RawMessagingGroup { - id: string; - channel_type: string; - platform_id: string; -} - -interface RawWiring { - id: string; - messaging_group_id: string; - agent_group_id: string; - engage_mode: string; - engage_pattern: string | null; - sender_scope: string; - ignored_message_policy: string; - session_mode: string; - priority: number; -} - -const REVERSE_FILE_RE = /^lid-mapping-(\d+)_reverse\.json$/; - -/** - * Read store/auth/lid-mapping-*_reverse.json into a Map. - * Returns an empty Map if the directory doesn't exist. - */ -function readReverseMappings(authDir: string): Map { - const out = new Map(); - if (!fs.existsSync(authDir)) return out; - for (const entry of fs.readdirSync(authDir)) { - const m = REVERSE_FILE_RE.exec(entry); - if (!m) continue; - const lidUser = m[1]; - try { - const raw = fs.readFileSync(path.join(authDir, entry), 'utf-8').trim(); - // The file content is a JSON-encoded string: `""` - const phoneUser = JSON.parse(raw); - if (typeof phoneUser !== 'string' || phoneUser.length === 0) continue; - out.set(lidUser, phoneUser); - } catch { - // Skip malformed entries — best-effort. - } - } - return out; -} - -function phoneUserOf(jid: string): string { - return jid.split('@')[0].split(':')[0]; -} - -function main(): void { - const authDir = path.join(process.cwd(), 'store', 'auth'); - const reverse = readReverseMappings(authDir); - - if (reverse.size === 0) { - console.log('SKIPPED:no lid-mapping-*_reverse.json files in store/auth'); - process.exit(0); - } - - // phoneUser → lidJid (the form we'll write to messaging_groups) - const phoneUserToLidJid = new Map(); - for (const [lidUser, phoneUser] of reverse) { - phoneUserToLidJid.set(phoneUser, `${lidUser}@lid`); - } - - const v2DbPath = path.join(DATA_DIR, 'v2.db'); - if (!fs.existsSync(v2DbPath)) { - console.error('FAIL:v2.db not found — run db step first'); - process.exit(1); - } - - const v2Db = initDb(v2DbPath); - runMigrations(v2Db); - - const phoneRows = v2Db - .prepare( - `SELECT id, channel_type, platform_id FROM messaging_groups - WHERE channel_type='whatsapp' AND platform_id LIKE '%@s.whatsapp.net'`, - ) - .all() as RawMessagingGroup[]; - - if (phoneRows.length === 0) { - console.log('SKIPPED:no whatsapp DM messaging_groups to resolve'); - v2Db.close(); - process.exit(0); - } - - // Pull existing wirings so each new alias gets the same agent_group + - // engage rules as the phone-keyed row. - const placeholders = phoneRows.map(() => '?').join(','); - const wiringRows = v2Db - .prepare(`SELECT * FROM messaging_group_agents WHERE messaging_group_id IN (${placeholders})`) - .all(...phoneRows.map((r) => r.id)) as RawWiring[]; - - const wiringsByMg = new Map(); - for (const w of wiringRows) { - const arr = wiringsByMg.get(w.messaging_group_id) ?? []; - arr.push(w); - wiringsByMg.set(w.messaging_group_id, arr); - } - - let resolved = 0; - let aliased = 0; - const createdAt = new Date().toISOString(); - - for (const row of phoneRows) { - const phoneUser = phoneUserOf(row.platform_id); - const lidJid = phoneUserToLidJid.get(phoneUser); - if (!lidJid) continue; - resolved++; - - let lidMg = getMessagingGroupByPlatform('whatsapp', lidJid); - if (!lidMg) { - createMessagingGroup({ - id: generateId('mg'), - channel_type: 'whatsapp', - platform_id: lidJid, - name: null, - is_group: 0, - unknown_sender_policy: 'public', - created_at: createdAt, - }); - lidMg = getMessagingGroupByPlatform('whatsapp', lidJid)!; - } - - const wirings = wiringsByMg.get(row.id) ?? []; - for (const w of wirings) { - if (getMessagingGroupAgentByPair(lidMg.id, w.agent_group_id)) continue; - createMessagingGroupAgent({ - id: generateId('mga'), - messaging_group_id: lidMg.id, - agent_group_id: w.agent_group_id, - engage_mode: w.engage_mode as 'pattern' | 'mention' | 'mention-sticky', - engage_pattern: w.engage_pattern, - sender_scope: w.sender_scope as 'all' | 'admins', - ignored_message_policy: w.ignored_message_policy as 'drop' | 'queue', - session_mode: w.session_mode as 'shared' | 'thread', - priority: w.priority, - created_at: createdAt, - }); - aliased++; - } - } - - v2Db.close(); - console.log( - `OK:reverse_mappings=${reverse.size},phone_dms=${phoneRows.length},lids_resolved=${resolved},aliased=${aliased}`, - ); -} - -main(); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 09c82ac..93a7e87 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -256,7 +256,7 @@ export function _resetStuckProcessingRowsForTesting( session: Session, reason: string, ): void { - resetStuckProcessingRows(inDb, outDb, session, reason); + resetStuckProcessingRows(inDb, outDb, session, reason, outDb); } function resetStuckProcessingRows( @@ -264,6 +264,7 @@ function resetStuckProcessingRows( outDb: Database.Database, session: Session, reason: string, + writableOutDb?: Database.Database, ): void { const claims = getProcessingClaims(outDb); const now = Date.now(); @@ -300,19 +301,17 @@ function resetStuckProcessingRows( // would re-read them, see the old status_changed timestamp, conclude the // freshly respawned container is stuck, and SIGKILL it before its // agent-runner has a chance to run clearStaleProcessingAcks() on startup. - // We're safe to write outbound.db here because we just killed the container - // that owned it (or it crashed and left no writer behind). - // outDb was opened readonly for reads above; reopen with write access for this delete. - let outDbRw: Database.Database | null = null; + const ownsDb = !writableOutDb; + let useDb: Database.Database | null = writableOutDb ?? null; try { - outDbRw = openOutboundDbRw(session.agent_group_id, session.id); - const cleared = deleteOrphanProcessingClaims(outDbRw); + if (!useDb) useDb = openOutboundDbRw(session.agent_group_id, session.id); + const cleared = deleteOrphanProcessingClaims(useDb); if (cleared > 0) { log.info('Cleared orphan processing claims', { sessionId: session.id, cleared, reason }); } } catch (err) { log.warn('Failed to clear orphan processing claims', { sessionId: session.id, err }); } finally { - outDbRw?.close(); + if (ownsDb) useDb?.close(); } }