#!/usr/bin/env node /** * Restart-durability verification via file-backed `src/db.node.ts`. * * Proves conversation state survives a process restart. The app persists the * agent transcript to SQLite (db.node.ts), so a second turn in the same thread — * served by a BRAND NEW process on the same DB file — replays the first turn's * assistant reply from durable storage. * * Flow (all offline, net-guarded, stub provider): * 1. server1 on DB_A: T1 signed mention, stub replyText = DURABILITY_MARKER. * SIGKILL server1. * 2. server2 on DB_A (fresh process, same DB): T2 signed mention in the SAME * thread. Assert (i) T2 delivers a final on the wire; (ii) T2's provider * request replays the marker (T1's assistant reply, loaded from the DB). * 3. NEGATIVE CONTROL — server3 on a DIFFERENT fresh DB_B: the same follow-up * turn's provider request must NOT contain the marker (no shared durable * storage → no replay). This proves the assertion measures durability. * 4. DURABLE CLAIMS + THREAD REGISTRY (SqliteSlackStateStore, sibling * `.state` file): on yet another fresh process sharing DB_A, * (i) a byte-identical redelivery of T1's event_id posts NO new final; * (ii) a new-event_id message with T1's (channel, ts) posts NO new final; * (iii) an implicit (mention-free) thread reply IS admitted and answered — * the joined thread survived the restart. Negative control on DB_B: the * same implicit reply produces nothing (thread never started there). * 5. Run the focused live-Agent helper: edit Agent config after a thread's * first turn, restart, and prove the next reply uses the current Agent * config. Agent edits intentionally apply everywhere on the next message. * * Run with Node 24.x (see .nvmrc): * node scripts/verify-durability.mjs */ import { spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { REPO_ROOT, assertNodeVersion, buildNodeServer, delay, loadFake, loadTsModule, postSignedEvent, seedOfflineDemoChannelConfig, seedOfflineSlackAuthority, spawnReadyServer, stopChild, waitForFinals, } from './lib/offline-harness.mjs'; const DURABILITY_MARKER = 'DURABILITY_MARKER_ALPHA'; const EXEC_CHANNEL = 'C_EXEC'; const ROOT_TS = '1782770400.000100'; // Negative checks wait until Slack/provider traffic has been idle for // quietMs. T1 calibrates it on a cold server: a wrongly admitted turn must be // able to reach the fake well inside the window, so a slow host widens it. const MIN_QUIET_MS = 1500; let quietMs = MIN_QUIET_MS; function log(line) { console.log(line); } const results = []; function record(name, passed, detail) { results.push({ name, passed }); log(`${passed ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); } /** A signed mention-free channel thread reply (implicit continuation). */ function threadReply({ eventId, ts, threadTs }) { return { token: 'verification-token-not-a-secret', team_id: 'TDEMO', api_app_id: 'ADEMO', event_id: eventId, event_time: 1782770400, type: 'event_callback', event: { type: 'message', channel_type: 'channel', user: 'U_ALICE', text: 'and what changed since the summary?', ts, channel: EXEC_CHANNEL, event_ts: ts, thread_ts: threadTs, }, }; } /** A signed app_mention in C_EXEC. `threadTs` set → threaded follow-up (same key). */ function mention({ eventId, ts, threadTs }) { return { token: 'verification-token-not-a-secret', team_id: 'TDEMO', api_app_id: 'ADEMO', event_id: eventId, event_time: 1782770400, type: 'event_callback', event: { type: 'app_mention', user: 'U_ALICE', text: '<@UBOT> please use channel context and draft an exec summary', ts, channel: EXEC_CHANNEL, event_ts: ts, ...(threadTs ? { thread_ts: threadTs } : {}), }, }; } async function runServerTurn({ serverEntry, fakeUrl, dbPath, netGuardLog, payload }) { // Each restart probe takes a fresh port; a port lost to another process // between allocation and bind is retried rather than failing the gate. const { child, eventsUrl, getOutput } = await spawnReadyServer({ serverEntry, fakeUrl, netGuardLog, env: { TAG_DB_PATH: dbPath, // Pin the state DB to the seeded `${dbPath}.state` explicitly: spawnServer // forwards ambient process.env, so an exported SLACK_STATE_DB_PATH (e.g. // from a live-Slack shell) would otherwise redirect every server here to // the operator's live state store and merge the three "isolated" DBs. SLACK_STATE_DB_PATH: `${dbPath}.state`, CHICKPEA_CREDENTIAL_KEYRING_PATH: `${dbPath}.credential-keyring.json`, CHICKPEA_AUTH_SECRET: '9d'.repeat(32), SLACK_TAG_MODEL: 'local-stub/parity-stub-1', }, }); try { await postSignedEvent(eventsUrl, payload); } catch (error) { await stopChild(child); throw error; } return { child, eventsUrl, getOutput }; } /** Milliseconds from now until the fake sees a call past `index`, or `timeoutMs`. */ async function firstWireAfter(index, timeoutMs = 15_000) { const startedAt = Date.now(); while (backend.wireLog.length <= index) { if (Date.now() - startedAt > timeoutMs) return timeoutMs; await delay(5); } return Date.now() - startedAt; } // Load every TypeScript dependency before the restart probes begin. Registering // tsx after repeatedly spawning and SIGKILLing server processes can leave its // esbuild loader waiting indefinitely on Linux; module loading is setup, not // part of the durability behavior this harness is meant to exercise. const { FakeSlackBackend } = await loadFake(); const { SqliteMemoryStateStore } = await loadTsModule('src/memory/store.ts'); const { SqliteConfigStore } = await loadTsModule('src/config/store.ts'); const { agentRoutineDeletion, SqliteRoutineStore } = await loadTsModule('src/routines/store.ts'); const { hashRoutineValue } = await loadTsModule('src/routines/ids.ts'); const backend = new FakeSlackBackend({ slack: { identity: { appId: 'ADEMO', teamId: 'TDEMO', botUserId: 'UBOT' }, channels: [{ id: EXEC_CHANNEL, name: 'exec', isMember: true }], channelMembers: { [EXEC_CHANNEL]: ['U_ALICE', 'UBOT'] }, workspaceUsers: [ { id: 'U_ALICE', teamId: 'TDEMO' }, { id: 'UBOT', teamId: 'TDEMO', isBot: true, isAppUser: true }, ], }, provider: { mode: 'ok', replyText: DURABILITY_MARKER }, }); const fake = await backend.listen(); log(`fake backend listening at ${fake.url}`); const netGuardLog = join(mkdtempSync(join(tmpdir(), 'flue-dur-guard-')), 'external-hosts.log'); const dbA = join(mkdtempSync(join(tmpdir(), 'flue-dur-dbA-')), 'flue.db'); const dbB = join(mkdtempSync(join(tmpdir(), 'flue-dur-dbB-')), 'flue.db'); // dbC is reserved for the thread-registry negative control: no turn ever runs // on it before the implicit reply (dbB already saw a mention with ROOT_TS as // its thread key, which would legitimately register the thread there). const dbC = join(mkdtempSync(join(tmpdir(), 'flue-dur-dbC-')), 'flue.db'); try { const serverEntry = await buildNodeServer(); log(`built node server: ${serverEntry}`); log(`node ${assertNodeVersion()} DB_A=${dbA} DB_B=${dbB}`); await seedOfflineDemoChannelConfig(`${dbA}.state`); await seedOfflineDemoChannelConfig(`${dbB}.state`); await seedOfflineDemoChannelConfig(`${dbC}.state`); for (const dbPath of [dbA, dbB, dbC]) { await seedOfflineSlackAuthority({ stateDbPath: `${dbPath}.state`, keyringPath: `${dbPath}.credential-keyring.json`, canonicalOrigin: 'http://127.0.0.1', teamId: 'TDEMO', slackUserId: 'UALICE01', appId: 'ADEMO', botUserId: 'UBOT', }); } // --- Turn 1 on DB_A, then kill the process. --- { const { child, getOutput } = await runServerTurn({ serverEntry, fakeUrl: fake.url, dbPath: dbA, netGuardLog, payload: mention({ eventId: 'Ev_DUR_T1', ts: ROOT_TS }), }); // No I/O runs between the ack resolving above and this line. const firstWire = firstWireAfter(backend.wireLog.length); const finals = await waitForFinals(backend, 1, 15_000); const t1Final = finals.at(-1); const firstWireMs = await firstWire; quietMs = Math.max(MIN_QUIET_MS, 3 * firstWireMs); log(`• cold-server ack to first fake call: ${firstWireMs}ms; negative checks wait for ${quietMs}ms of quiet`); // This case proves restart after a completed turn. A Slack HTTP effect is // visible before its local receipt is committed; killing at that boundary // instead tests ambiguous delivery recovery and can strand T2 behind T1. // Wait for the receipt, not an arbitrary sleep or another model response. const state = new DatabaseSync(`${dbA}.state`, { readOnly: true }); let delivered = false; try { const deadline = Date.now() + 15_000; while (Date.now() < deadline) { const job = state.prepare('SELECT delivered FROM turn_jobs WHERE id = ?') .get(`msg:${EXEC_CHANNEL}:${ROOT_TS}`); if (job?.delivered === 1) { delivered = true; break; } await delay(25); } } finally { state.close(); await stopChild(child); } if (!delivered) throw new Error(`T1 delivery receipt was not persisted:\n${getOutput()}`); record( 'T1 delivers a final carrying the durability marker, then server SIGKILLed', finals.length === 1 && !!t1Final && t1Final.text.includes(DURABILITY_MARKER), `finals=${finals.length} markerInFinal=${!!t1Final && t1Final.text.includes(DURABILITY_MARKER)}`, ); } // --- Turn 2 on DB_A: fresh process, same DB. Marker must replay. --- backend.reset(); { const { child, getOutput } = await runServerTurn({ serverEntry, fakeUrl: fake.url, dbPath: dbA, netGuardLog, payload: mention({ eventId: 'Ev_DUR_T2', ts: '1782770500.000100', threadTs: ROOT_TS }), }); const finals = await waitForFinals(backend, 1, 15_000); const t2Final = finals.at(-1); if (finals.length === 0) log(`T2 server output:\n${getOutput()}`); const providerCalls = backend.providerCalls(); const providerReplaysMarker = providerCalls.some((call) => JSON.stringify(call.body).includes(DURABILITY_MARKER), ); await stopChild(child); record( 'T2 (new process, same DB) delivers a final', finals.length === 1 && !!t2Final, `finals=${finals.length}`, ); record( 'T2 provider request REPLAYS the marker from durable storage', providerReplaysMarker, `providerCalls=${providerCalls.length} replaysMarker=${providerReplaysMarker}`, ); } // --- Negative control: follow-up on a DIFFERENT fresh DB → no replay. --- backend.reset(); { const { child } = await runServerTurn({ serverEntry, fakeUrl: fake.url, dbPath: dbB, netGuardLog, payload: mention({ eventId: 'Ev_DUR_NEG', ts: '1782770600.000100', threadTs: ROOT_TS }), }); const finals = await waitForFinals(backend, 1, 15_000); const providerCalls = backend.providerCalls(); const providerHasMarker = providerCalls.some((call) => JSON.stringify(call.body).includes(DURABILITY_MARKER), ); await stopChild(child); record( 'NEGATIVE CONTROL: fresh DB → provider request does NOT contain the marker', finals.length === 1 && !providerHasMarker, `finals=${finals.length} markerLeaked=${providerHasMarker}`, ); } // --- Durable claims + registry: yet another fresh process on DB_A. --- backend.reset(); { const { child, eventsUrl, getOutput } = await runServerTurn({ serverEntry, fakeUrl: fake.url, dbPath: dbA, netGuardLog, // Byte-identical redelivery of T1's event (same event_id, same ts). payload: mention({ eventId: 'Ev_DUR_T1', ts: ROOT_TS }), }); await backend.quiesce(quietMs, quietMs + 15_000); const afterRedelivery = backend.finals().length; record( 'DURABLE CLAIMS: redelivered event_id after restart posts NO new final', afterRedelivery === 0, `finals=${afterRedelivery}`, ); // New event_id, same (channel, message-ts): the msg: claim must hold. const twin = await postSignedEvent(eventsUrl, mention({ eventId: 'Ev_DUR_TWIN', ts: ROOT_TS })); await backend.quiesce(quietMs, quietMs + 15_000); const afterTwin = backend.finals().length; record( 'DURABLE CLAIMS: new event_id with the same (channel, ts) posts NO new final', twin.status === 200 && afterTwin === 0, `ackStatus=${twin.status} finals=${afterTwin}`, ); // Mention-free thread reply: the durable registry admits it post-restart. await postSignedEvent( eventsUrl, threadReply({ eventId: 'Ev_DUR_IMPL', ts: '1782770700.000100', threadTs: ROOT_TS }), ); const implicitFinals = await waitForFinals(backend, 1, 15_000); if (implicitFinals.length === 0) log(`registry server output:\n${getOutput()}`); await stopChild(child); record( 'DURABLE REGISTRY: implicit thread reply IS admitted after restart (one final)', implicitFinals.length === 1, `finals=${implicitFinals.length}`, ); } // --- Registry negative control: implicit reply on untouched DB_C → silence. --- backend.reset(); { const { child } = await runServerTurn({ serverEntry, fakeUrl: fake.url, dbPath: dbC, netGuardLog, payload: threadReply({ eventId: 'Ev_DUR_IMPL_NEG', ts: '1782770800.000100', threadTs: ROOT_TS }), }); await backend.quiesce(quietMs, quietMs + 15_000); const finals = backend.finals().length; await stopChild(child); record( 'NEGATIVE CONTROL: implicit reply on a DB whose thread never started posts NO final', finals === 0, `finals=${finals}`, ); } // --- One-body Agent memory: restart, compatibility open, and scrub. --- { const memoryPath = `${dbA}.state`; const memorySentinel = 'MEMORY_DURABILITY_SENTINEL_ALPHA'; const priorLegacyFlag = process.env.SLACK_TAG_MEMORY_ENABLED; process.env.SLACK_TAG_MEMORY_ENABLED = 'false'; let memoryStore; try { memoryStore = new SqliteMemoryStateStore(memoryPath); await memoryStore.putAgentMemory({ agentId: 'agent_default', body: memorySentinel, expectedRevision: 0, }); memoryStore.close(); memoryStore = undefined; // A config-only open stands in for rollback code that knows nothing about // the additive memory tables: it must boot and leave them untouched. const configOnly = new SqliteConfigStore(memoryPath, agentRoutineDeletion, { agents: [], grants: [] }); await configOnly.listAgents(); configOnly.close(); memoryStore = new SqliteMemoryStateStore(memoryPath); const restarted = await memoryStore.getAgentMemory('agent_default'); record( 'MEMORY DURABILITY: Agent body survives close/restart and legacy false is inert', restarted.body === memorySentinel && restarted.revision === 1, `revision=${String(restarted.revision)}`, ); await memoryStore.putAgentMemory({ agentId: 'agent_default', body: '', expectedRevision: 1, }); memoryStore.close(); memoryStore = undefined; memoryStore = new SqliteMemoryStateStore(memoryPath); const forgotten = await memoryStore.getAgentMemory('agent_default'); memoryStore.close(); memoryStore = undefined; record( 'MEMORY DURABILITY: clearing the Agent body survives restart without history', forgotten.body === '' && forgotten.revision === 2, `revision=${String(forgotten.revision)}`, ); record( 'MEMORY DURABILITY: deleted sentinel is absent from the raw state file', !readFileSync(memoryPath).toString('latin1').includes(memorySentinel), ); } finally { memoryStore?.close(); if (priorLegacyFlag === undefined) delete process.env.SLACK_TAG_MEMORY_ENABLED; else process.env.SLACK_TAG_MEMORY_ENABLED = priorLegacyFlag; } } // --- Scheduled work: definition/run/idempotency survive a state restart. --- { const routinePath = join(mkdtempSync(join(tmpdir(), 'flue-routine-dur-')), 'state.db'); const routineNow = Date.now(); const tokenHash = hashRoutineValue('routine-durability-confirmation'); const definition = { name: 'Durability routine', description: 'Proves scheduled-work restart behavior.', taskText: 'Inspect current state and update the disposable acceptance record.', triggerKind: 'schedule', scheduleInput: '0 * * * *', scheduleJson: JSON.stringify({ version: 1, kind: 'cron', expression: '0 * * * *' }), timezone: 'UTC', outputPolicy: 'post', authorityMode: 'live_channel_v1', }; const draft = { action: 'create', routineId: 'routine_durability', definition, nextRunAt: routineNow + 60 * 60_000, projectedDailyStarts: 24, reservations: [{ windowStart: routineNow + 60 * 60_000, count: 1 }], }; const previewHash = hashRoutineValue(JSON.stringify(draft)); let routineStore = new SqliteRoutineStore(routinePath, () => routineNow); try { await routineStore.putConfirmation({ confirmationId: 'rconfirm_durability', tokenHash, actorId: 'U_ALICE', actorClass: 'member', workspaceId: 'TDEMO', channelId: EXEC_CHANNEL, draft, previewHash, expiresAt: routineNow + 15 * 60_000, }); const routine = await routineStore.confirm({ tokenHash, actorId: 'U_ALICE', workspaceId: 'TDEMO', channelId: EXEC_CHANNEL, previewHash, idempotencyKey: 'routine:durability:confirm', }); const occurrenceInput = { runId: 'rrun_durability', idempotencyKey: 'routine:durability:occurrence', routineId: routine.id, routineVersion: routine.version, scheduledFor: routineNow + 60 * 60_000, triggerSource: 'schedule', requestedBy: null, queuedAt: routineNow, deadlineAt: routineNow + 15 * 60_000, }; await routineStore.createOccurrence(occurrenceInput); routineStore.close(); routineStore = new SqliteRoutineStore(routinePath, () => routineNow); const restarted = await routineStore.getRoutine(routine.id); const replay = await routineStore.createOccurrence(occurrenceInput); const runs = await routineStore.listRuns({ routineId: routine.id }); record( 'ROUTINE DURABILITY: definition and occurrence survive restart with one idempotent run', restarted?.taskText === definition.taskText && replay.id === 'rrun_durability' && runs.length === 1, `routine=${String(restarted?.id)} run=${replay.id} runs=${runs.length}`, ); } finally { routineStore.close(); } } // --- Net guard: zero external traffic. --- { const attempted = existsSync(netGuardLog) ? readFileSync(netGuardLog, 'utf8').trim() : ''; record('NET_GUARD_LOG empty -> zero external traffic', attempted === '', attempted || 'none'); } } catch (error) { record('durability harness', false, error instanceof Error ? error.stack : String(error)); } finally { await backend.close(); } log('\nRunning live-Agent config restart verification...'); const snapshotRun = spawnSync( process.execPath, [join(REPO_ROOT, 'scripts', 'verify-snapshot-durability.mjs')], { cwd: REPO_ROOT, env: process.env, stdio: 'inherit', }, ); record( 'LIVE AGENT CONFIG: edited instructions survive restart and reach the same thread', snapshotRun.status === 0, snapshotRun.error ? snapshotRun.error.message : `exit=${snapshotRun.status ?? 'signal'}${snapshotRun.signal ? ` signal=${snapshotRun.signal}` : ''}`, ); const failed = results.filter((result) => !result.passed); log(`\n${results.length - failed.length}/${results.length} checks passed`); process.exit(failed.length === 0 ? 0 : 1);