mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 03:01:35 +00:00
fix(sqlite): preserve state across maintenance races (#113216)
* fix(sqlite): harden destructive maintenance safety * fix(sessions): compact transcripts atomically * fix(cron): prevent orphan scratch writes
This commit is contained in:
@@ -57,7 +57,7 @@ export function compactDoctorSessionSqliteTarget(
|
||||
}
|
||||
|
||||
const compact = compactDoctorSqliteFile({
|
||||
afterMutation: () => {
|
||||
afterSuccess: () => {
|
||||
requireQuarantineCleared();
|
||||
ensureOpenClawAgentDatabasePermissions(sqlitePath, {
|
||||
agentId: target.agentId,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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<string, SessionEntry>();
|
||||
let currentIdentity = new Map<string, SessionEntry>();
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<typeof replaceSqliteTranscriptEvents>[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<typeof replaceSqliteTranscriptEvents>[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 = {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user