diff --git a/src/commands/doctor-session-sqlite-compact.ts b/src/commands/doctor-session-sqlite-compact.ts index a386761b33de..2bf629473922 100644 --- a/src/commands/doctor-session-sqlite-compact.ts +++ b/src/commands/doctor-session-sqlite-compact.ts @@ -57,7 +57,7 @@ export function compactDoctorSessionSqliteTarget( } const compact = compactDoctorSqliteFile({ - afterMutation: () => { + afterSuccess: () => { requireQuarantineCleared(); ensureOpenClawAgentDatabasePermissions(sqlitePath, { agentId: target.agentId, diff --git a/src/commands/doctor-session-sqlite.test.ts b/src/commands/doctor-session-sqlite.test.ts index c32436755271..ef1681671822 100644 --- a/src/commands/doctor-session-sqlite.test.ts +++ b/src/commands/doctor-session-sqlite.test.ts @@ -817,20 +817,13 @@ describe("runDoctorSessionSqlite", () => { fs.renameSync(sqlitePath, realPath); fs.symlinkSync(realPath, sqlitePath); - const report = await runDoctorSessionSqlite({ - env: store.env, - mode: "compact", - store: store.storePath, - }); - - expect(report.targets[0]?.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: "sqlite_compact_failed", - message: expect.stringMatching(/not a regular file/iu), - }), - ]), - ); + await expect( + runDoctorSessionSqlite({ + env: store.env, + mode: "compact", + store: store.storePath, + }), + ).rejects.toThrow(/Cannot run session SQLite compact.*symbolic-link path/iu); }, ); @@ -876,6 +869,14 @@ describe("runDoctorSessionSqlite", () => { it("rejects stale secondary indexes before compacting and quarantines them in recovery", async () => { const { sqlitePath, store } = await createImportedStoreForCompaction(); createUnsafeIndexDrift(sqlitePath); + expect( + recordOpenClawDatabaseQuarantine({ + env: store.env, + kind: "agent", + path: sqlitePath, + reason: "stale secondary index", + }), + ).toBe(true); const report = await runDoctorSessionSqlite({ env: store.env, @@ -893,6 +894,9 @@ describe("runDoctorSessionSqlite", () => { }), ]), ); + expect(readOpenClawDatabaseQuarantine(sqlitePath, { env: store.env })?.reason).toBe( + "stale secondary index", + ); const recovery = await runDoctorSessionSqlite({ env: store.env, diff --git a/src/commands/doctor-session-sqlite.ts b/src/commands/doctor-session-sqlite.ts index 6f3cc329c7e6..e02ee1c60bba 100644 --- a/src/commands/doctor-session-sqlite.ts +++ b/src/commands/doctor-session-sqlite.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { getRuntimeConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; import { resolveSessionFilePath } from "../config/sessions/paths.js"; import { importSqliteSessionRows, @@ -66,7 +67,7 @@ import { type DoctorSessionSqliteTargetReport, } from "./doctor-session-sqlite-types.js"; import { - assertDoctorSqliteMaintenancePathsNotHardLinked, + assertDoctorSqliteMaintenancePathsNotAliased, isDestructiveDoctorSessionSqliteMode, } from "./doctor-sqlite-maintenance-lock.js"; export { @@ -104,9 +105,11 @@ export async function runDoctorSessionSqlite( store: options.store, }); if (isDestructiveDoctorSessionSqliteMode(options.mode)) { - assertDoctorSqliteMaintenancePathsNotHardLinked( + const maintenancePaths = resolveDoctorSessionSqliteMaintenancePaths(targets); + assertDoctorSqliteMaintenancePathsNotAliased( `session SQLite ${options.mode}`, - resolveDoctorSessionSqliteMaintenancePaths(targets), + maintenancePaths, + resolveDoctorSessionSqliteMaintenanceRoots(targets, env), ); } if (options.mode === "restore") { @@ -172,6 +175,44 @@ function resolveDoctorSessionSqliteMaintenancePaths( return [...protectedPaths]; } +function resolveDoctorSessionSqliteMaintenanceRoots( + targets: readonly SessionStoreTarget[], + env: NodeJS.ProcessEnv, +): string[] { + const stateDir = path.resolve(resolveStateDir(env)); + const roots = new Set([stateDir]); + for (const target of targets) { + const sqlitePath = resolveTargetSqlitePath(target); + if (isPathWithin(stateDir, target.storePath) && isPathWithin(stateDir, sqlitePath)) { + continue; + } + const commonRoot = commonPathAncestor(path.dirname(target.storePath), path.dirname(sqlitePath)); + const parentRoot = path.dirname(commonRoot); + roots.add(parentRoot === path.parse(commonRoot).root ? commonRoot : parentRoot); + } + return [...roots]; +} + +function isPathWithin(rootPath: string, candidatePath: string): boolean { + const relativePath = path.relative(rootPath, path.resolve(candidatePath)); + return ( + relativePath === "" || (!relativePath.startsWith(`..${path.sep}`) && relativePath !== "..") + ); +} + +function commonPathAncestor(leftPath: string, rightPath: string): string { + let currentPath = path.resolve(leftPath); + const resolvedRightPath = path.resolve(rightPath); + while (!isPathWithin(currentPath, resolvedRightPath)) { + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return currentPath; + } + currentPath = parentPath; + } + return currentPath; +} + // Direct store migrations are scoped by path; broader agent discovery needs runtime config. function resolveDoctorSessionSqliteConfig(options: DoctorSessionSqliteOptions): OpenClawConfig { if (options.cfg) { diff --git a/src/commands/doctor-sqlite-compact.ts b/src/commands/doctor-sqlite-compact.ts index ba77212e04bb..d825efe60f61 100644 --- a/src/commands/doctor-sqlite-compact.ts +++ b/src/commands/doctor-sqlite-compact.ts @@ -21,7 +21,7 @@ type DoctorSqliteCompactResult = { }; type DoctorSqliteCompactOptions = { - afterMutation?: () => void; + afterSuccess?: () => void; busyTimeoutMs?: number; sqlitePath: string; validateBeforeMutation?: (database: DatabaseSync) => void; @@ -39,7 +39,6 @@ export function compactDoctorSqliteFile( ): DoctorSqliteCompactResult { const sqlite = requireNodeSqlite(); const database = new sqlite.DatabaseSync(options.sqlitePath); - let mutationStarted = false; let operationError: unknown; let result: DoctorSqliteCompactResult | undefined; try { @@ -50,7 +49,6 @@ export function compactDoctorSqliteFile( options.validateBeforeMutation?.(database); const before = readCompactSnapshot(database, options.sqlitePath); assertSqliteIntegrity(database, options.sqlitePath); - mutationStarted = true; checkpointTruncate(database, options.sqlitePath); database.exec("PRAGMA auto_vacuum = INCREMENTAL;"); database.exec("VACUUM;"); @@ -73,9 +71,9 @@ export function compactDoctorSqliteFile( } catch (error) { operationError ??= error; } - if (mutationStarted) { + if (operationError === undefined && result) { try { - options.afterMutation?.(); + options.afterSuccess?.(); } catch (error) { operationError ??= error; } diff --git a/src/commands/doctor-sqlite-maintenance-lock.test.ts b/src/commands/doctor-sqlite-maintenance-lock.test.ts index 205c1f2a72cc..a166492bc024 100644 --- a/src/commands/doctor-sqlite-maintenance-lock.test.ts +++ b/src/commands/doctor-sqlite-maintenance-lock.test.ts @@ -336,6 +336,68 @@ describe("doctor SQLite maintenance lock", () => { await expect(fs.readFile(externalPath, "utf8")).resolves.toBe("{}\n"); }); + it.skipIf(process.platform === "win32")( + "refuses in-state symbolic links before destructive maintenance", + async () => { + const fixture = await createLockFixture(); + const sessionsDir = path.join(fixture.env.OPENCLAW_STATE_DIR, "agents", "main", "agent"); + const targetPath = path.join(sessionsDir, "sidecar-target"); + const sidecarPath = path.join(sessionsDir, "openclaw-agent.sqlite-wal"); + await fs.mkdir(sessionsDir, { recursive: true }); + await fs.writeFile(targetPath, "owned target\n", "utf8"); + await fs.symlink(targetPath, sidecarPath); + const run = vi.fn(); + + await expect( + withDoctorSqliteMaintenanceLock( + { + env: fixture.env, + operation: "session SQLite compaction", + protectedPaths: [sidecarPath], + run, + }, + { lockOptions: fixture.lockOptions }, + ), + ).rejects.toThrow(/symbolic-link path/); + expect(run).not.toHaveBeenCalled(); + await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("owned target\n"); + }, + ); + + it.skipIf(process.platform === "win32")( + "refuses symbolic links in owned path ancestors", + async () => { + const fixture = await createLockFixture(); + const agentsDir = path.join(fixture.env.OPENCLAW_STATE_DIR, "agents"); + const realAgentDir = path.join(fixture.env.OPENCLAW_STATE_DIR, "real-main"); + const aliasedAgentDir = path.join(agentsDir, "main"); + const databasePath = path.join(aliasedAgentDir, "agent", "openclaw-agent.sqlite"); + await fs.mkdir(path.dirname(path.join(realAgentDir, "agent", "placeholder")), { + recursive: true, + }); + await fs.mkdir(agentsDir, { recursive: true }); + await fs.writeFile(path.join(realAgentDir, "agent", "openclaw-agent.sqlite"), "owned\n"); + await fs.symlink(realAgentDir, aliasedAgentDir, "dir"); + const run = vi.fn(); + + await expect( + withDoctorSqliteMaintenanceLock( + { + env: fixture.env, + operation: "session SQLite compaction", + protectedPaths: [databasePath], + run, + }, + { lockOptions: fixture.lockOptions }, + ), + ).rejects.toThrow(/symbolic-link path component/); + expect(run).not.toHaveBeenCalled(); + await expect( + fs.readFile(path.join(realAgentDir, "agent", "openclaw-agent.sqlite"), "utf8"), + ).resolves.toBe("owned\n"); + }, + ); + it("allows explicit destructive targets owned by the locked state directory", async () => { const fixture = await createLockFixture(); const storePath = path.join( diff --git a/src/commands/doctor-sqlite-maintenance-lock.ts b/src/commands/doctor-sqlite-maintenance-lock.ts index 3f28070391bb..3fdf4fccbc9f 100644 --- a/src/commands/doctor-sqlite-maintenance-lock.ts +++ b/src/commands/doctor-sqlite-maintenance-lock.ts @@ -73,24 +73,32 @@ function assertMaintenancePathsOwnedByStateDir( ); } } - assertDoctorSqliteMaintenancePathsNotHardLinked(operation, protectedPaths); + assertDoctorSqliteMaintenancePathsNotAliased(operation, protectedPaths, [stateDir]); } /** Reject file aliases that destructive SQLite maintenance would mutate in place. */ -export function assertDoctorSqliteMaintenancePathsNotHardLinked( +export function assertDoctorSqliteMaintenancePathsNotAliased( operation: string, protectedPaths: readonly string[], + ownershipRoots: readonly string[] = [], ): void { + const resolvedRoots = ownershipRoots.map((candidate) => path.resolve(candidate)); for (const protectedPath of new Set(protectedPaths.map((candidate) => path.resolve(candidate)))) { + assertPathComponentsNotSymbolicLinks(operation, protectedPath, resolvedRoots); let stat: fs.Stats; try { - stat = fs.statSync(protectedPath); + stat = fs.lstatSync(protectedPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { continue; } throw error; } + if (stat.isSymbolicLink()) { + throw new Error( + `Cannot run ${operation} for a symbolic-link path: ${protectedPath}. Replace the symbolic link with an owned regular file and retry.`, + ); + } if (stat.isFile() && stat.nlink > 1) { throw new Error( `Cannot run ${operation} for a hard-linked path: ${protectedPath}. Remove the additional hard link and retry.`, @@ -99,6 +107,36 @@ export function assertDoctorSqliteMaintenancePathsNotHardLinked( } } +function assertPathComponentsNotSymbolicLinks( + operation: string, + protectedPath: string, + ownershipRoots: readonly string[], +): void { + const rootPath = ownershipRoots.find((candidate) => isPathInside(candidate, protectedPath)); + if (!rootPath) { + return; + } + const relativePath = path.relative(rootPath, protectedPath); + let currentPath = rootPath; + for (const segment of relativePath.split(path.sep).filter(Boolean)) { + currentPath = path.join(currentPath, segment); + let stat: fs.Stats; + try { + stat = fs.lstatSync(currentPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + if (stat.isSymbolicLink()) { + throw new Error( + `Cannot run ${operation} through a symbolic-link path component: ${currentPath}. Replace the symbolic link with an owned directory or regular file and retry.`, + ); + } + } +} + export function isDestructiveDoctorSessionSqliteMode(mode: DoctorSessionSqliteMode): boolean { return mode === "import" || mode === "compact" || mode === "restore" || mode === "recover"; } diff --git a/src/commands/doctor-state-sqlite-compact.test.ts b/src/commands/doctor-state-sqlite-compact.test.ts index 4eb96621fd4f..811b8defd82e 100644 --- a/src/commands/doctor-state-sqlite-compact.test.ts +++ b/src/commands/doctor-state-sqlite-compact.test.ts @@ -285,6 +285,14 @@ describe("runDoctorStateSqliteCompact", () => { it("treats a busy truncating checkpoint as failure", async () => { const env = createStateEnv(); const sqlitePath = seedStateDatabase({ env }); + expect( + recordOpenClawDatabaseQuarantine({ + env, + kind: "state", + path: sqlitePath, + reason: "busy checkpoint", + }), + ).toBe(true); const sqlite = requireNodeSqlite(); const reader = new sqlite.DatabaseSync(sqlitePath); const writer = new sqlite.DatabaseSync(sqlitePath); @@ -297,6 +305,7 @@ describe("runDoctorStateSqliteCompact", () => { /checkpoint remained busy/, ); expect(readPragma(writer, "auto_vacuum")).toBe(0); + expect(readOpenClawDatabaseQuarantine(sqlitePath, { env })?.reason).toBe("busy checkpoint"); } finally { reader.exec("ROLLBACK;"); reader.close(); diff --git a/src/commands/doctor-state-sqlite-compact.ts b/src/commands/doctor-state-sqlite-compact.ts index 5e3b78df66f1..d506ed496a60 100644 --- a/src/commands/doctor-state-sqlite-compact.ts +++ b/src/commands/doctor-state-sqlite-compact.ts @@ -73,7 +73,7 @@ export async function runDoctorStateSqliteCompact( } const compact = compactDoctorSqliteFile({ - afterMutation: () => { + afterSuccess: () => { if (!clearOpenClawDatabaseQuarantine(sqlitePath, { env })) { throw new Error( `OpenClaw state database ${sqlitePath} was compacted, but its persisted quarantine record could not be cleared. Rerun openclaw doctor --fix so the database is not refused again.`, diff --git a/src/config/sessions/session-accessor.sqlite-transcript-write.ts b/src/config/sessions/session-accessor.sqlite-transcript-write.ts index f580119bee8a..ef6f4e41e308 100644 --- a/src/config/sessions/session-accessor.sqlite-transcript-write.ts +++ b/src/config/sessions/session-accessor.sqlite-transcript-write.ts @@ -17,9 +17,11 @@ import type { } from "./session-accessor.sqlite-contract.js"; import type { ResolvedSessionEntryRow } from "./session-accessor.sqlite-entry-store.js"; import { + assertSqliteSessionEntrySelectionUnchanged, collectSessionEntryLookupKeys, deleteLegacySessionEntryRows, readSessionEntryRow, + readSqliteSessionEntrySelectionSnapshot, readSqliteSessionIdentitySnapshot, writeSessionEntry, } from "./session-accessor.sqlite-entry-store.js"; @@ -145,16 +147,27 @@ export function replaceSqliteTranscriptEventsSync( export async function trimSqliteTranscriptForManualCompact( scope: SessionTranscriptAccessScope, selectRetainedLines: (lines: readonly string[]) => readonly string[] | null, + options: { nowMs?: number } = {}, ): Promise<{ trimmed: false } | { archivedPath: string; kept: number; trimmed: true }> { const resolved = resolveSqliteTranscriptScope(scope); return await runExclusiveSqliteSessionWrite(resolved, async () => { const database = openOpenClawAgentDatabase(toDatabaseOptions(resolved)); const snapshot = readSqliteTranscriptSnapshot(database, resolved.sessionId); + const sessionSnapshot = readSqliteSessionEntrySelectionSnapshot( + database, + resolved.sessionKey, + true, + ); const lines = snapshot.rows.map((row) => row.eventJson); const retainedLines = selectRetainedLines(lines); if (!retainedLines) { return { trimmed: false }; } + if (sessionSnapshot.selected?.entry.sessionId !== resolved.sessionId) { + throw new Error( + `Cannot compact SQLite transcript ${resolved.sessionId} without its current session entry`, + ); + } const retainedEvents = retainedLines.map((line) => JSON.parse(line) as TranscriptEvent); const archivedPath = writeSqliteTranscriptArchive({ archiveDirectory: resolveSqliteTranscriptArchiveDirectory(resolved), @@ -164,10 +177,42 @@ export async function trimSqliteTranscriptForManualCompact( }); // Published archives can be reused by another process before this commit. // Retain them on failure so a sibling operation never loses its durable proof. + let previousIdentity = new Map(); + let currentIdentity = new Map(); runOpenClawAgentWriteTransaction((writeDatabase) => { assertSqliteTranscriptSnapshotUnchanged(writeDatabase, resolved.sessionId, snapshot.rows); + const freshSessionSnapshot = readSqliteSessionEntrySelectionSnapshot( + writeDatabase, + resolved.sessionKey, + true, + ); + assertSqliteSessionEntrySelectionUnchanged( + sessionSnapshot, + freshSessionSnapshot, + "session.transcript.manual-compact", + ); + const freshEntry = freshSessionSnapshot.selected?.entry; + if (!freshEntry || freshEntry.sessionId !== resolved.sessionId) { + throw new Error(`SQLite session changed before compacting ${resolved.sessionId}`); + } + const identityKeys = collectSessionEntryLookupKeys(writeDatabase, resolved.sessionKey); + previousIdentity = readSqliteSessionIdentitySnapshot(writeDatabase, identityKeys); replaceSqliteTranscriptEventsInTransaction(writeDatabase, resolved, retainedEvents); + const nextEntry = cloneSessionEntry(freshEntry); + delete nextEntry.contextBudgetStatus; + delete nextEntry.inputTokens; + delete nextEntry.outputTokens; + delete nextEntry.totalTokens; + delete nextEntry.totalTokensFresh; + nextEntry.updatedAt = options.nowMs ?? Date.now(); + // The transcript rewrite and token invalidation describe one generation. + // Keep them in this transaction so either both become visible or neither does. + writeSessionEntry(writeDatabase, resolved.sessionKey, nextEntry, { + previousEntry: freshEntry, + }); + currentIdentity = readSqliteSessionIdentitySnapshot(writeDatabase, identityKeys); }, toDatabaseOptions(resolved)); + emitCommittedSessionIdentityDiff(previousIdentity, currentIdentity); return { archivedPath, kept: retainedLines.length, trimmed: true }; }); } diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index 4d0aaaef4f47..fea04603b53d 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -2544,6 +2544,146 @@ describe("session accessor seam", () => { expect(await loadTranscriptEvents(scope)).toEqual(records); }); + it("keeps no-op manual compaction tolerant of a missing current session entry", async () => { + await expect( + trimSqliteTranscriptForManualCompact( + { + agentId: "main", + sessionId: "99999999-9999-4999-8999-999999999999", + sessionKey: "agent:main:main", + storePath, + }, + () => null, + ), + ).resolves.toEqual({ trimmed: false }); + }); + + it("rolls back the manual compact row trim when token metadata cannot be cleared", async () => { + const sessionId = "77777777-7777-4777-8777-777777777777"; + const sessionKey = "agent:main:main"; + const scope = { + agentId: "main", + sessionId, + sessionKey, + storePath, + }; + const records = [ + { type: "session", version: 3, id: sessionId, timestamp: "2026-06-19T12:00:00.000Z" }, + ...[1, 2, 3, 4].map((index) => ({ + type: "message", + id: `entry-${index}`, + parentId: index === 1 ? null : `entry-${index - 1}`, + timestamp: `2026-06-19T12:00:0${index}.000Z`, + message: { role: "user", content: `message ${index}`, timestamp: index }, + })), + ]; + await upsertSessionEntry(scope, { + inputTokens: 10, + outputTokens: 20, + sessionId, + totalTokens: 30, + totalTokensFresh: true, + updatedAt: 100, + }); + await replaceSqliteTranscriptEvents( + scope, + records as Parameters[1], + ); + const entryBeforeCompact = loadSessionEntry(scope); + const databasePath = expectDefined( + resolveSqliteTargetFromSessionStorePath(storePath, { agentId: "main" }).path, + "manual compact database path", + ); + const database = openOpenClawAgentDatabase({ agentId: "main", path: databasePath }); + database.db.exec(` + CREATE TRIGGER reject_manual_compact_metadata_update + BEFORE UPDATE OF entry_json ON session_nodes + WHEN OLD.session_key = '${sessionKey}' + BEGIN + SELECT RAISE(ABORT, 'injected manual compact metadata failure'); + END; + `); + + await expect( + trimSessionTranscriptForManualCompact(scope, { maxLines: 3, nowMs: 500 }), + ).rejects.toThrow("injected manual compact metadata failure"); + database.db.exec("DROP TRIGGER reject_manual_compact_metadata_update;"); + + expect(await loadTranscriptEvents(scope)).toEqual(records); + expect(loadSessionEntry(scope)).toEqual(entryBeforeCompact); + const archiveNames = fs.readdirSync(tempDir).filter((name) => name.includes(".bak.")); + expect(archiveNames).toHaveLength(1); + expect( + readSessionArchiveContentSync( + path.join(tempDir, expectDefined(archiveNames[0], "manual compact archive name")), + ), + ).toBe(`${records.map((record) => JSON.stringify(record)).join("\n")}\n`); + }); + + it("rejects a manual compact when session metadata changes after its snapshot", async () => { + const sessionId = "88888888-8888-4888-8888-888888888888"; + const scope = { + agentId: "main", + sessionId, + sessionKey: "agent:main:main", + storePath, + }; + const records = [ + { type: "session", version: 3, id: sessionId, timestamp: "2026-06-19T12:00:00.000Z" }, + ...[1, 2, 3, 4].map((index) => ({ + type: "message", + id: `entry-${index}`, + parentId: index === 1 ? null : `entry-${index - 1}`, + timestamp: `2026-06-19T12:00:0${index}.000Z`, + message: { role: "user", content: `message ${index}`, timestamp: index }, + })), + ]; + await upsertSessionEntry(scope, { + sessionId, + totalTokens: 30, + totalTokensFresh: true, + updatedAt: 100, + }); + await replaceSqliteTranscriptEvents( + scope, + records as Parameters[1], + ); + + await expect( + trimSqliteTranscriptForManualCompact( + scope, + (lines) => { + replaceSqliteSessionEntrySync(scope, { + label: "concurrent metadata", + sessionId, + totalTokens: 40, + totalTokensFresh: true, + updatedAt: 200, + }); + return lines.slice(0, 1); + }, + { nowMs: 500 }, + ), + ).rejects.toThrow( + "SQLite session state changed while preparing session.transcript.manual-compact", + ); + + expect(await loadTranscriptEvents(scope)).toEqual(records); + expect(loadSessionEntry(scope)).toMatchObject({ + label: "concurrent metadata", + totalTokens: 40, + totalTokensFresh: true, + updatedAt: 200, + }); + const archiveNames = fs.readdirSync(tempDir).filter((name) => name.includes(".bak.")); + expect(archiveNames).toHaveLength(1); + expect( + readSessionArchiveContentSync( + path.join(tempDir, expectDefined(archiveNames[0], "manual compact archive name")), + ), + ).toBe(`${records.map((record) => JSON.stringify(record)).join("\n")}\n`); + }); + it("preserves the backup and rows written after the manual compact snapshot", async () => { const sessionId = "55555555-5555-4555-8555-555555555555"; const scope = { diff --git a/src/config/sessions/session-accessor.transcript.ts b/src/config/sessions/session-accessor.transcript.ts index aee7eaae62c2..d378338eabf3 100644 --- a/src/config/sessions/session-accessor.transcript.ts +++ b/src/config/sessions/session-accessor.transcript.ts @@ -1,5 +1,4 @@ import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; -import { patchSessionEntry } from "./session-accessor.entry.js"; import { appendSqliteTranscriptEvent, appendSqliteTranscriptEventSync, @@ -236,46 +235,33 @@ export async function trimSessionTranscriptForManualCompact( const maxLines = Math.max(1, Math.floor(params.maxLines)); const maxTailLines = Math.max(0, maxLines - 1); let declined: SessionTranscriptManualTrimResult = { compacted: false, reason: "no transcript" }; - const trimmed = await trimSqliteTranscriptForManualCompact(scope, (lines) => { - if (lines.length === 0) { - declined = { compacted: false, reason: "no transcript" }; - return null; - } - if (lines.length <= maxLines) { - declined = { compacted: false, kept: lines.length }; - return null; - } - const tailLines = lines.slice(1); - const retainedLines = normalizeManualCompactTranscriptLines( - lines[0], - maxTailLines > 0 ? tailLines.slice(-maxTailLines) : [], - ); - if (!retainedLines) { - declined = { compacted: false, kept: 0 }; - return null; - } - return retainedLines; - }); + const trimmed = await trimSqliteTranscriptForManualCompact( + scope, + (lines) => { + if (lines.length === 0) { + declined = { compacted: false, reason: "no transcript" }; + return null; + } + if (lines.length <= maxLines) { + declined = { compacted: false, kept: lines.length }; + return null; + } + const tailLines = lines.slice(1); + const retainedLines = normalizeManualCompactTranscriptLines( + lines[0], + maxTailLines > 0 ? tailLines.slice(-maxTailLines) : [], + ); + if (!retainedLines) { + declined = { compacted: false, kept: 0 }; + return null; + } + return retainedLines; + }, + params.nowMs === undefined ? {} : { nowMs: params.nowMs }, + ); if (!trimmed.trimmed) { return declined; } - await patchSessionEntry( - { - ...scope, - sessionKey: scope.sessionKey, - storePath: scope.storePath, - }, - (entry) => { - delete entry.contextBudgetStatus; - delete entry.inputTokens; - delete entry.outputTokens; - delete entry.totalTokens; - delete entry.totalTokensFresh; - entry.updatedAt = params.nowMs ?? Date.now(); - return entry; - }, - { replaceEntry: true }, - ); return { archived: trimmed.archivedPath, compacted: true, kept: trimmed.kept }; } diff --git a/src/cron/scratch-store.test.ts b/src/cron/scratch-store.test.ts index cc474db0d51c..d610dcb492e0 100644 --- a/src/cron/scratch-store.test.ts +++ b/src/cron/scratch-store.test.ts @@ -2,13 +2,19 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { + closeOpenClawStateDatabaseForTest, + runOpenClawStateWriteTransaction, +} from "../state/openclaw-state-db.js"; import { CRON_JOB_SCRATCH_MAX_BYTES } from "./scratch-contract.js"; import { hashCronScratchSource, readCronJobScratchState, writeCronJobScratch, } from "./scratch-store.js"; +import { cronStoreKey } from "./store/key.js"; +import { replaceCronRows, upsertCronJobRow } from "./store/row-codec.js"; +import type { CronJob } from "./types.js"; const tempDirs: string[] = []; @@ -21,10 +27,27 @@ async function createFixture() { const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-scratch-")); tempDirs.push(root); const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(root, "state") }; - return { + const fixture = { storePath: path.join(root, "cron", "jobs.json"), options: { env }, }; + const job: CronJob = { + id: "job-1", + name: "scratch-owner", + enabled: true, + createdAtMs: 1, + updatedAtMs: 1, + schedule: { kind: "every", everyMs: 60_000, anchorMs: 0 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "test" }, + state: {}, + }; + runOpenClawStateWriteTransaction( + ({ db }) => upsertCronJobRow(db, cronStoreKey(fixture.storePath), job, 0), + fixture.options, + ); + return fixture; } describe("cron job scratch store", () => { @@ -118,6 +141,27 @@ describe("cron job scratch store", () => { }); }); + it("rejects a late write after the owning job is durably deleted", async () => { + const fixture = await createFixture(); + runOpenClawStateWriteTransaction( + ({ db }) => replaceCronRows(db, cronStoreKey(fixture.storePath), { version: 1, jobs: [] }), + fixture.options, + ); + + expect( + writeCronJobScratch({ + ...fixture, + jobId: "job-1", + content: "orphaned heartbeat scratch", + expectedRevision: 0, + nowMs: 10, + }), + ).toEqual({ ok: false, reason: "revision-conflict", currentRevision: 0 }); + expect(readCronJobScratchState(fixture.storePath, "job-1", fixture.options)).toEqual({ + currentRevision: 0, + }); + }); + it("records migration provenance and clears it on plain rewrites", async () => { const fixture = await createFixture(); const content = "# Monitor\n\nCheck mail.\n"; diff --git a/src/cron/scratch-store.ts b/src/cron/scratch-store.ts index 452d51707ecb..7d6b71819724 100644 --- a/src/cron/scratch-store.ts +++ b/src/cron/scratch-store.ts @@ -142,11 +142,24 @@ export function writeCronJobScratch(params: { const nowMs = params.nowMs ?? Date.now(); return runOpenClawStateWriteTransaction( ({ db }) => { + const cronDb = getCronStoreKysely(db); const { currentRevision } = readScratchStateFromDatabase(db, storeKey, params.jobId); - if (params.expectedRevision !== undefined && params.expectedRevision !== currentRevision) { + const owningJob = executeSqliteQuerySync( + db, + cronDb + .selectFrom("cron_jobs") + .select("job_id") + .where("store_key", "=", storeKey) + .where("job_id", "=", params.jobId), + ).rows[0]; + // Job ownership and scratch revision are one CAS boundary. A heartbeat + // finishing after durable job deletion must not recreate orphan scratch. + if ( + !owningJob || + (params.expectedRevision !== undefined && params.expectedRevision !== currentRevision) + ) { return { ok: false, reason: "revision-conflict", currentRevision } as const; } - const cronDb = getCronStoreKysely(db); if (params.content === null && currentRevision === 0) { return { ok: true, currentRevision } as const; } diff --git a/src/infra/heartbeat-runner.tool-response.test.ts b/src/infra/heartbeat-runner.tool-response.test.ts index 3f9761467b73..dbcdfe87ce02 100644 --- a/src/infra/heartbeat-runner.tool-response.test.ts +++ b/src/infra/heartbeat-runner.tool-response.test.ts @@ -15,8 +15,12 @@ import { } from "../auto-reply/reply/agent-runner-failure-copy.js"; import type { OpenClawConfig } from "../config/config.js"; import { patchSessionEntry } from "../config/sessions/session-accessor.js"; -import { readCronJobScratchState } from "../cron/scratch-store.js"; -import { resolveCronJobsStorePath } from "../cron/store.js"; +import { + deleteCronJobScratch, + readCronJobScratchState, + readHeartbeatMonitorScratch, +} from "../cron/scratch-store.js"; +import { resolveCronJobsStorePath, saveCronJobsStore } from "../cron/store.js"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { stripTrailingHeartbeatNotifyFalse } from "./heartbeat-delivery-normalization.js"; @@ -321,6 +325,44 @@ describe("runHeartbeatOnce heartbeat response tool", () => { }); }); + it("does not recreate scratch when its monitor is deleted while the heartbeat runs", async () => { + await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { + const cfg = createConfig({ tmpDir, storePath }); + const cronStorePath = resolveCronJobsStorePath(); + const monitor = readHeartbeatMonitorScratch(cronStorePath, "main"); + expect(monitor).toBeDefined(); + if (!monitor) { + throw new Error("Expected seeded heartbeat monitor"); + } + deleteCronJobScratch(cronStorePath, monitor.jobId); + await seedMainSessionStore(storePath, cfg, { + lastChannel: "telegram", + lastProvider: "telegram", + lastTo: TELEGRAM_GROUP, + }); + replySpy.mockImplementation(async () => { + await saveCronJobsStore(cronStorePath, { version: 1, jobs: [] }); + return createHeartbeatToolResponsePayload({ + outcome: "progress", + notify: false, + summary: "Updated monitor context.", + scratch: "late scratch write", + }); + }); + + const result = await runHeartbeatOnce({ + cfg, + source: "manual", + deps: createDeps({ sendTelegram: vi.fn(), getReplyFromConfig: replySpy }), + }); + + expect(result.status).toBe("ran"); + expect(readCronJobScratchState(cronStorePath, monitor.jobId)).toEqual({ + currentRevision: 0, + }); + }); + }); + it("persists a meaningful quiet outcome for the base session", async () => { await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { vi.stubEnv("OPENCLAW_STATE_DIR", tmpDir); diff --git a/test/cli-state-sqlite.e2e.test.ts b/test/cli-state-sqlite.e2e.test.ts index 8cc4436e3a3b..f5461d8dc44a 100644 --- a/test/cli-state-sqlite.e2e.test.ts +++ b/test/cli-state-sqlite.e2e.test.ts @@ -260,6 +260,70 @@ describe("SQLite CLI maintenance ownership", () => { ); }, 90_000); + it.skipIf(process.platform === "win32")( + "rejects symbolic-linked SQLite sidecars before destructive maintenance", + async () => { + await withTempHome( + async (tempHome) => { + const stateDir = path.join(tempHome, ".openclaw"); + const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json"); + const sqlitePath = path.join( + stateDir, + "agents", + "main", + "agent", + "openclaw-agent.sqlite", + ); + const targetPath = path.join(stateDir, "agents", "main", "agent", "sidecar-target"); + fs.mkdirSync(path.dirname(storePath), { recursive: true }); + fs.mkdirSync(path.dirname(sqlitePath), { recursive: true }); + fs.writeFileSync(storePath, "{}\n", "utf8"); + fs.writeFileSync(targetPath, "owned target\n", "utf8"); + fs.symlinkSync(targetPath, `${sqlitePath}-wal`); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: tempHome, + USERPROFILE: tempHome, + OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TEST_FAST: "1", + }; + delete env.OPENCLAW_CONFIG_PATH; + delete env.OPENCLAW_HOME; + delete env.VITEST; + + const entry = path.resolve(process.cwd(), "src/entry.ts"); + const result = spawnSync( + process.execPath, + [ + "--import", + "tsx", + entry, + "doctor", + "--session-sqlite", + "compact", + "--session-sqlite-store", + storePath, + "--json", + ], + { + cwd: process.cwd(), + env, + encoding: "utf8", + timeout: 60_000, + }, + ); + + expect(result.status).not.toBe(0); + expect(`${result.stderr}\n${result.stdout}`).toContain("symbolic-link path"); + expect(fs.readFileSync(targetPath, "utf8")).toBe("owned target\n"); + }, + { prefix: "openclaw-session-sqlite-symlink-sidecar-cli-" }, + ); + }, + 90_000, + ); + it("rejects hard-linked SQLite sidecars discovered through configured session stores", async () => { await withTempHome( async (tempHome) => {