fix(sessions): fsync archives through writable handles (#110191)

Fixes #110152.
This commit is contained in:
Josh Lehman
2026-07-17 15:16:50 -07:00
committed by GitHub
parent 5e41074009
commit 867a2296eb
6 changed files with 54 additions and 17 deletions
+1 -1
View File
@@ -1987,7 +1987,7 @@
"test:unit:fast:audit": "node scripts/test-unit-fast-audit.mjs",
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
"test:watch": "node scripts/test-projects.mjs --watch",
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/config/sessions/store.session-lifecycle-mutation.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
"ts-topology": "node --import tsx scripts/ts-topology.ts",
+2 -2
View File
@@ -49,9 +49,9 @@ const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/;
const NODE_SCOPE_RE =
/^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/;
const WINDOWS_SCOPE_RE =
/^(src\/process\/|src\/infra\/windows-install-roots\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|scripts\/(?:install\.ps1|openclaw-cross-os-release-checks\.ts|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)|lib\/(?:format-generated-module\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
/^(src\/config\/sessions\/(?:session-accessor\.sqlite-archive|store\.session-lifecycle-mutation\.test)\.ts$|src\/process\/|src\/infra\/windows-install-roots\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|scripts\/(?:install\.ps1|openclaw-cross-os-release-checks\.ts|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)|lib\/(?:format-generated-module\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
const WINDOWS_TEST_SCOPE_RE =
/^(src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/windows-install-roots\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|test\/scripts\/(?:format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/;
/^(src\/config\/sessions\/store\.session-lifecycle-mutation\.test\.ts$|src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/windows-install-roots\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|test\/scripts\/(?:format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/;
const WINDOWS_DAEMON_SCOPE_RE =
/^src\/daemon\/(?:schtasks(?:[-.][^/]+)?|runtime-hints\.windows-paths(?:\.test)?|test-helpers\/schtasks-(?:base-mocks|fixtures))\.ts$/;
const CONTROL_UI_I18N_SCOPE_RE =
@@ -102,8 +102,7 @@ function writeSqliteTranscriptArchive(params: {
}
const tempPath = `${archivePath}.${randomUUID()}.tmp`;
try {
fs.writeFileSync(tempPath, encoded.bytes, { flag: "wx", mode: 0o600 });
fsyncRegularFile(tempPath);
writeDurableFileExclusive(tempPath, encoded.bytes);
fs.renameSync(tempPath, archivePath);
fsyncDirectory(params.archiveDirectory);
return archivePath;
@@ -118,9 +117,12 @@ function writeSqliteTranscriptArchive(params: {
throw new Error(`Could not create SQLite transcript archive for ${params.sessionId}`);
}
function fsyncRegularFile(filePath: string): void {
const fd = fs.openSync(filePath, "r");
// Windows rejects fsync on read-only handles, so keep the exclusive writable
// descriptor open through both the write and durability boundary.
function writeDurableFileExclusive(filePath: string, content: Buffer): void {
const fd = fs.openSync(filePath, "wx", 0o600);
try {
fs.writeFileSync(fd, content);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
@@ -319,7 +319,7 @@ describe("session store lifecycle mutations", () => {
expect(readSearchState()).toEqual({ fts: [], watermarks: [] });
});
it("durably writes SQLite transcript archives before deleting entry rows", async () => {
it("fsyncs SQLite transcript archives through their writable descriptor before deletion", async () => {
const now = Date.now();
await replaceSessionEntry(
{ sessionKey: "agent:main:durable-delete", storePath },
@@ -333,17 +333,19 @@ describe("session store lifecycle mutations", () => {
[createTranscriptEvent("durable-delete-session", "durable archive first")],
);
const originalWriteFileSync = fs.writeFileSync;
const entryObservedDuringArchiveWrite: boolean[] = [];
const writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation((...args) => {
const filePath = String(args[0]);
if (filePath.includes("durable-delete-session.jsonl.deleted.")) {
entryObservedDuringArchiveWrite.push(
const originalRenameSync = fs.renameSync;
const entryObservedDuringArchiveRename: boolean[] = [];
const openSpy = vi.spyOn(fs, "openSync");
const fsyncSpy = vi.spyOn(fs, "fsyncSync");
const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((...args) => {
const archivePath = String(args[1]);
if (archivePath.includes("durable-delete-session.jsonl.deleted.")) {
entryObservedDuringArchiveRename.push(
loadSessionEntry({ sessionKey: "agent:main:durable-delete", storePath })?.sessionId ===
"durable-delete-session",
);
}
return originalWriteFileSync(...args);
return originalRenameSync(...args);
});
try {
@@ -358,9 +360,18 @@ describe("session store lifecycle mutations", () => {
expect(result.deleted).toBe(true);
expect(result.archivedTranscripts).toHaveLength(1);
expect(entryObservedDuringArchiveWrite).toEqual([true]);
expect(entryObservedDuringArchiveRename).toEqual([true]);
const archiveTempOpenIndexes = openSpy.mock.calls.flatMap((args, index) =>
String(args[0]).includes("durable-delete-session.jsonl.deleted.") ? [index] : [],
);
expect(archiveTempOpenIndexes).toHaveLength(1);
const archiveTempOpenIndex = archiveTempOpenIndexes[0] ?? -1;
expect(openSpy.mock.calls[archiveTempOpenIndex]?.[1]).toBe("wx");
expect(fsyncSpy).toHaveBeenCalledWith(openSpy.mock.results[archiveTempOpenIndex]?.value);
} finally {
writeSpy.mockRestore();
renameSpy.mockRestore();
fsyncSpy.mockRestore();
openSpy.mockRestore();
}
});
@@ -0,0 +1,18 @@
// Windows CI scope tests cover paths with platform-specific runtime contracts.
import { describe, expect, it } from "vitest";
const { detectChangedScope } = await import("../../scripts/ci-changed-scope.mjs");
describe("detectChangedScope Windows routing", () => {
it("routes SQLite transcript archive changes to Windows", () => {
for (const archivePath of [
"src/config/sessions/session-accessor.sqlite-archive.ts",
"src/config/sessions/store.session-lifecycle-mutation.test.ts",
]) {
expect(detectChangedScope([archivePath]), archivePath).toMatchObject({
runNode: true,
runWindows: true,
});
}
});
});
+6
View File
@@ -169,6 +169,12 @@ describe("package scripts", () => {
);
});
it("runs SQLite transcript archive durability coverage in Windows CI", () => {
expect(readPackageJson().scripts["test:windows:ci"]).toContain(
"src/config/sessions/store.session-lifecycle-mutation.test.ts",
);
});
it("runs cross-OS installer behavior coverage in Windows CI", () => {
expect(readPackageJson().scripts["test:windows:ci"]).toContain(
"test/scripts/openclaw-cross-os-installer.windows.test.ts",