
















@@ -6,6 +6,7 @@ import { backupVerifyCommand } from "../commands/backup-verify.js";
66import type { RuntimeEnv } from "../runtime.js";
77import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
88import {
9+__test as backupCreateInternals,
910buildExtensionsNodeModulesFilter,
1011createBackupArchive,
1112formatBackupCreateSummary,
@@ -23,6 +24,7 @@ function makeResult(overrides: Partial<BackupCreateResult> = {}): BackupCreateRe
2324verified: false,
2425assets: [],
2526skipped: [],
27+skippedVolatileCount: 0,
2628 ...overrides,
2729};
2830}
@@ -106,6 +108,159 @@ describe("formatBackupCreateSummary", () => {
106108])("$name", ({ result, expected }) => {
107109expect(formatBackupCreateSummary(result)).toEqual(expected);
108110});
111+112+it("surfaces the volatile skip count in the summary", () => {
113+expect(
114+formatBackupCreateSummary(
115+makeResult({
116+assets: [
117+{
118+kind: "state",
119+sourcePath: "/state",
120+archivePath: "archive/state",
121+displayPath: "~/.openclaw",
122+},
123+],
124+skippedVolatileCount: 3,
125+}),
126+),
127+).toEqual([
128+"Backup archive: /tmp/openclaw-backup.tar.gz",
129+"Included 1 path:",
130+"- state: ~/.openclaw",
131+"Created /tmp/openclaw-backup.tar.gz",
132+"Skipped 3 volatile files (live sessions, cron logs, queues, sockets, pid/tmp).",
133+]);
134+});
135+});
136+137+describe("isTarEofRaceError", () => {
138+const { isTarEofRaceError } = backupCreateInternals;
139+140+it.each([
141+"did not encounter expected EOF",
142+"encountered unexpected EOF",
143+"TAR_BAD_ARCHIVE: Unrecognized archive format",
144+"Truncated input (needed 512 more bytes, only 0 available) (TAR_BAD_ARCHIVE)",
145+])("matches tar-specific EOF-class error: %s", (message) => {
146+expect(isTarEofRaceError(new Error(message))).toBe(true);
147+});
148+149+it("matches errors by code even when the message is empty", () => {
150+expect(isTarEofRaceError(Object.assign(new Error(""), { code: "EOF" }))).toBe(true);
151+});
152+153+it.each([
154+"EOF occurred in violation of protocol",
155+"unexpected eof while reading",
156+"ran out of EOF markers",
157+"permission denied",
158+"",
159+])("does not match unrelated errors: %s", (message) => {
160+expect(isTarEofRaceError(new Error(message))).toBe(false);
161+});
162+163+it("rejects non-object inputs", () => {
164+expect(isTarEofRaceError(null)).toBe(false);
165+expect(isTarEofRaceError(undefined)).toBe(false);
166+expect(isTarEofRaceError("did not encounter expected EOF")).toBe(false);
167+});
168+});
169+170+describe("writeTarArchiveWithRetry", () => {
171+it("retries on EOF-class errors and eventually succeeds", async () => {
172+const eofErr = Object.assign(new Error("did not encounter expected EOF"), {
173+path: "/state/sessions/s-abc/transcript.jsonl",
174+});
175+const runTar = vi
176+.fn<() => Promise<void>>()
177+.mockRejectedValueOnce(eofErr)
178+.mockRejectedValueOnce(eofErr)
179+.mockResolvedValueOnce(undefined);
180+const log = vi.fn();
181+const sleep = vi.fn<(ms: number) => Promise<void>>().mockResolvedValue(undefined);
182+183+await backupCreateInternals.writeTarArchiveWithRetry({
184+tempArchivePath: "/tmp/backup.tar.gz.tmp",
185+ runTar,
186+ log,
187+sleepMs: sleep,
188+});
189+190+expect(runTar).toHaveBeenCalledTimes(3);
191+expect(sleep).toHaveBeenNthCalledWith(1, 10_000);
192+expect(sleep).toHaveBeenNthCalledWith(2, 20_000);
193+expect(log).toHaveBeenCalledTimes(2);
194+});
195+196+it("surfaces the offending path and attempt count after exhausting retries", async () => {
197+const eofErr = Object.assign(new Error("did not encounter expected EOF"), {
198+path: "/state/logs/gateway.jsonl",
199+});
200+const runTar = vi.fn<() => Promise<void>>().mockRejectedValue(eofErr);
201+const sleep = vi.fn<(ms: number) => Promise<void>>().mockResolvedValue(undefined);
202+203+await expect(
204+backupCreateInternals.writeTarArchiveWithRetry({
205+tempArchivePath: "/tmp/backup.tar.gz.tmp",
206+ runTar,
207+sleepMs: sleep,
208+}),
209+).rejects.toThrow(/last offending path: \/state\/logs\/gateway\.jsonl, after 3 attempts/);
210+expect(runTar).toHaveBeenCalledTimes(3);
211+});
212+213+it("lets callers reset per-attempt counters so retries report the final attempt's count, not a running sum", async () => {
214+// Simulate the caller's pattern: a closure counter populated by a filter
215+// that tar.c invokes while walking the tree. Each attempt re-walks the
216+// same tree, so the runTar closure must reset the counter before calling
217+// tar.c -- otherwise the reported count accumulates across attempts.
218+let skippedVolatileCount = 0;
219+const volatileFilesSeenPerAttempt = 5;
220+let attempt = 0;
221+222+const eofErr = Object.assign(new Error("did not encounter expected EOF"), {
223+path: "/state/sessions/s-abc/transcript.jsonl",
224+});
225+226+const runTar = vi.fn<() => Promise<void>>().mockImplementation(async () => {
227+attempt += 1;
228+skippedVolatileCount = 0;
229+for (let i = 0; i < volatileFilesSeenPerAttempt; i += 1) {
230+skippedVolatileCount += 1;
231+}
232+if (attempt < 3) {
233+throw eofErr;
234+}
235+});
236+const sleep = vi.fn<(ms: number) => Promise<void>>().mockResolvedValue(undefined);
237+238+await backupCreateInternals.writeTarArchiveWithRetry({
239+tempArchivePath: "/tmp/backup.tar.gz.tmp",
240+ runTar,
241+sleepMs: sleep,
242+});
243+244+expect(runTar).toHaveBeenCalledTimes(3);
245+// Without the reset, this would be 15 (5 * 3 attempts). With the reset,
246+// it equals the count from the final (successful) attempt.
247+expect(skippedVolatileCount).toBe(volatileFilesSeenPerAttempt);
248+});
249+250+it("does not retry on non-EOF errors", async () => {
251+const runTar = vi.fn<() => Promise<void>>().mockRejectedValue(new Error("permission denied"));
252+const sleep = vi.fn<(ms: number) => Promise<void>>().mockResolvedValue(undefined);
253+254+await expect(
255+backupCreateInternals.writeTarArchiveWithRetry({
256+tempArchivePath: "/tmp/backup.tar.gz.tmp",
257+ runTar,
258+sleepMs: sleep,
259+}),
260+).rejects.toThrow(/permission denied/);
261+expect(runTar).toHaveBeenCalledTimes(1);
262+expect(sleep).not.toHaveBeenCalled();
263+});
109264});
110265111266describe("buildExtensionsNodeModulesFilter", () => {
@@ -131,6 +286,65 @@ describe("buildExtensionsNodeModulesFilter", () => {
131286});
132287133288describe("createBackupArchive", () => {
289+it("skips current live volatile state files while preserving workspace locks", async () => {
290+await withOpenClawTestState(
291+{
292+layout: "split",
293+prefix: "openclaw-backup-volatile-",
294+scenario: "minimal",
295+},
296+async (state) => {
297+const outputDir = state.path("backups");
298+await state.writeConfig({
299+agents: {
300+list: [{ id: "main", default: true, workspace: state.workspaceDir }],
301+},
302+});
303+await fs.mkdir(outputDir, { recursive: true });
304+await fs.writeFile(path.join(state.workspaceDir, "Cargo.lock"), "workspace lock\n", "utf8");
305+await fs.writeFile(
306+path.join(state.workspaceDir, "pending.tmp"),
307+"workspace temp fixture\n",
308+"utf8",
309+);
310+await state.writeText("agents/main/sessions/live-session.jsonl", "session\n");
311+await state.writeText("sessions/legacy-session.jsonl", "legacy session\n");
312+await state.writeText("cron/runs/nightly.jsonl", "cron\n");
313+await state.writeText("logs/gateway.log", "log\n");
314+await state.writeJson("delivery-queue/message.json", { id: "delivery" });
315+await state.writeJson("session-delivery-queue/message.json", { id: "session-delivery" });
316+await state.writeText("tmp/staged.tmp", "tmp\n");
317+await state.writeText("gateway.pid", "123\n");
318+319+const result = await createBackupArchive({
320+output: outputDir,
321+includeWorkspace: true,
322+nowMs: Date.UTC(2026, 4, 9, 8, 0, 0),
323+});
324+const entries = await listArchiveEntries(result.archivePath);
325+326+expect(entries.some((entry) => entry.endsWith("/workspace/Cargo.lock"))).toBe(true);
327+expect(entries.some((entry) => entry.endsWith("/workspace/pending.tmp"))).toBe(true);
328+for (const suffix of [
329+"/state/agents/main/sessions/live-session.jsonl",
330+"/state/sessions/legacy-session.jsonl",
331+"/state/cron/runs/nightly.jsonl",
332+"/state/logs/gateway.log",
333+"/state/delivery-queue/message.json",
334+"/state/session-delivery-queue/message.json",
335+"/state/tmp/staged.tmp",
336+"/state/gateway.pid",
337+]) {
338+expect(
339+entries.some((entry) => entry.endsWith(suffix)),
340+suffix,
341+).toBe(false);
342+}
343+expect(result.skippedVolatileCount).toBe(8);
344+},
345+);
346+});
347+134348it("omits installed plugin node_modules from the real archive while keeping plugin files", async () => {
135349await withOpenClawTestState(
136350{
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。