diff --git a/CHANGELOG.md b/CHANGELOG.md index d68fa896b44..fab7a9ad243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Cron/Telegram: preserve explicit `:topic:` delivery targets over stale session-derived thread IDs when isolated cron announces to Telegram forum topics. Carries forward #59069; refs #49704 and #43808. Thanks @roytong9. - CLI/model probes: reject empty or whitespace-only `infer model run --prompt` values before calling local providers or the Gateway, so smoke checks do not spend provider calls on invalid turns. Fixes #73185. Thanks @iot2edge. - Gateway/media: route text-only `chat.send` image offloads through media-understanding fields so `agents.defaults.imageModel` can describe WebChat attachments instead of leaving only an opaque `media://inbound` marker. Fixes #72968. Thanks @vorajeeah. +- Gateway/sessions: remove automatic oversized `sessions.json` rotation backups, deprecate `session.maintenance.rotateBytes`, and teach `openclaw doctor --fix` to remove the ignored key so hot session writes no longer copy multi-MB stores. Refs #72338. Thanks @midhunmonachan and @DougButdorf. - Channels/Telegram: fail fast when Telegram rejects the startup `getMe` token probe with 401, so invalid or stale BotFather tokens are reported as token auth failures instead of misleading `deleteWebhook` cleanup failures. Fixes #47674. Thanks @samaedan-arch. - ACPX: keep generated Codex and Claude ACP wrapper startup paths working when remote or special state filesystems reject chmod, since OpenClaw invokes the wrappers through Node instead of executing them directly. Fixes #73333. Thanks @david-garcia-garcia. - CLI/onboarding: infer image input for common custom-provider vision model IDs, ask only for unknown models, and keep `--custom-image-input`/`--custom-text-input` overrides so vision-capable proxies do not get saved as text-only configs. Fixes #51869. Thanks @Antsoldier1974. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 203865d7b17..d89e8c0da0e 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -4fd357ae137b920586ce5760d461be586f4f9a94e49b73cad1f81110167cd9da config-baseline.json -f874cddd0744be277af58ef14261af7994aba669c642f613be10f92b095998ba config-baseline.core.json +f888e19429506211e4b8b4113594641825d300c0c0a721121092cae2201b721f config-baseline.json +481eb68ecf9538d8f6d9808af1a7416b05a3b5d00080552b955a77dbd90819e3 config-baseline.core.json a9f058ee9616e189dab7fc223e1207a49ae52b8490b8028935c9d0a2b16f81b2 config-baseline.channel.json 1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 0746cc4c1f9..87cbfac7fea 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -1153,7 +1153,6 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden mode: "warn", // warn | enforce pruneAfter: "30d", maxEntries: 500, - rotateBytes: "10mb", resetArchiveRetention: "30d", // duration or false maxDiskBytes: "500mb", // optional hard budget highWaterBytes: "400mb", // optional cleanup target @@ -1196,7 +1195,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - `mode`: `warn` emits warnings only; `enforce` applies cleanup. - `pruneAfter`: age cutoff for stale entries (default `30d`). - `maxEntries`: maximum number of entries in `sessions.json` (default `500`). Runtime writes batch cleanup with a small high-water buffer for production-sized caps; `openclaw sessions cleanup --enforce` applies the cap immediately. - - `rotateBytes`: rotate `sessions.json` when it exceeds this size (default `10mb`). + - `rotateBytes`: deprecated and ignored; `openclaw doctor --fix` removes it from older configs. - `resetArchiveRetention`: retention for `*.reset.` transcript archives. Defaults to `pruneAfter`; set `false` to disable. - `maxDiskBytes`: optional sessions-directory disk budget. In `warn` mode it logs warnings; in `enforce` mode it removes oldest artifacts/sessions first. - `highWaterBytes`: optional target after budget cleanup. Defaults to `80%` of `maxDiskBytes`. diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 29d47401a81..febc08e3c65 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -163,7 +163,6 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. mode: "warn", pruneAfter: "30d", maxEntries: 500, - rotateBytes: "10mb", resetArchiveRetention: "30d", // duration or false maxDiskBytes: "500mb", // optional highWaterBytes: "400mb", // optional (defaults to 80% of maxDiskBytes) diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 594ae40b18a..e8cedc70ccd 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -75,13 +75,14 @@ Session persistence has automatic maintenance controls (`session.maintenance`) f - `mode`: `warn` (default) or `enforce` - `pruneAfter`: stale-entry age cutoff (default `30d`) - `maxEntries`: cap entries in `sessions.json` (default `500`) -- `rotateBytes`: rotate `sessions.json` when oversized (default `10mb`) - `resetArchiveRetention`: retention for `*.reset.` transcript archives (default: same as `pruneAfter`; `false` disables cleanup) - `maxDiskBytes`: optional sessions-directory budget - `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`) Normal Gateway writes batch `maxEntries` cleanup for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. `openclaw sessions cleanup --enforce` still applies the configured cap immediately. +OpenClaw no longer creates automatic `sessions.json.bak.*` rotation backups during Gateway writes. The legacy `session.maintenance.rotateBytes` key is ignored and `openclaw doctor --fix` removes it from older configs. + Enforcement order for disk budget cleanup (`mode: "enforce"`): 1. Remove oldest archived, orphan transcript, or orphan trajectory artifacts first. diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index bb7653b28fe..1b1b9072a2e 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -156,6 +156,11 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } migrateThreadBinding(next.session, changes, "session"); + const sessionMaintenance = asRecord(asRecord(next.session)?.maintenance); + if (sessionMaintenance && "rotateBytes" in sessionMaintenance) { + delete sessionMaintenance.rotateBytes; + changes.push("Removed deprecated session.maintenance.rotateBytes."); + } const channels = asRecord(next.channels); for (const [channelId, channelRaw] of Object.entries(channels ?? {})) { if (channelId === "defaults") { @@ -333,6 +338,14 @@ vi.mock("../config/legacy.js", () => { 'session.threadBindings.ttlHours is legacy; use session.threadBindings.idleHours. Run "openclaw doctor --fix".', ); } + const sessionMaintenance = asRecord(asRecord(root.session)?.maintenance); + if (sessionMaintenance && "rotateBytes" in sessionMaintenance) { + addIssue( + issues, + ["session", "maintenance"], + 'session.maintenance.rotateBytes is deprecated and ignored; run "openclaw doctor --fix" to remove it.', + ); + } const xSearch = asRecord(asRecord(asRecord(root.tools)?.web)?.x_search); if (xSearch && "apiKey" in xSearch) { addIssue( @@ -1563,6 +1576,11 @@ describe("doctor config flow", () => { bridge: { bind: "auto" }, gateway: { auth: { mode: "token", token: "ok", extra: true } }, agents: { list: [{ id: "pi" }] }, + session: { + maintenance: { + rotateBytes: "10mb", + }, + }, browser: { relayBindHost: "0.0.0.0", profiles: { @@ -2304,6 +2322,9 @@ describe("doctor config flow", () => { bind?: string; }; session?: { + maintenance?: { + rotateBytes?: unknown; + }; threadBindings?: { idleHours?: number; ttlHours?: number; @@ -2348,6 +2369,7 @@ describe("doctor config flow", () => { every: "30m", }); expect(cfg.gateway?.bind).toBe("lan"); + expect(cfg.session?.maintenance?.rotateBytes).toBeUndefined(); expect(cfg.session?.threadBindings).toMatchObject({ idleHours: 24, }); @@ -2414,6 +2436,9 @@ describe("doctor config flow", () => { }, }, session: { + maintenance: { + rotateBytes: "10mb", + }, threadBindings: { ttlHours: 24, }, @@ -2454,6 +2479,8 @@ describe("doctor config flow", () => { expect(legacyMessages).toContain("does not rewrite this shape automatically"); expect(legacyMessages).toContain("session.threadBindings.ttlHours"); expect(legacyMessages).toContain("session.threadBindings.idleHours"); + expect(legacyMessages).toContain("session.maintenance.rotateBytes"); + expect(legacyMessages).toContain("deprecated and ignored"); expect(legacyMessages).toContain("channels..threadBindings.ttlHours"); expect(legacyMessages).toContain("channels..threadBindings.idleHours"); expect(legacyMessages).toContain("talk:"); diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 7ac75cedb6e..bb91f66ee4e 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -19,6 +19,28 @@ function migrateLegacyConfigForTest(raw: unknown): { : { config: next as OpenClawConfig, changes }; } +describe("legacy session maintenance migrate", () => { + it("removes deprecated session.maintenance.rotateBytes", () => { + const res = migrateLegacyConfigForTest({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "30d", + maxEntries: 500, + rotateBytes: "10mb", + }, + }, + }); + + expect(res.config?.session?.maintenance).toEqual({ + mode: "enforce", + pruneAfter: "30d", + maxEntries: 500, + }); + expect(res.changes).toContain("Removed deprecated session.maintenance.rotateBytes."); + }); +}); + describe("legacy migrate audio transcription", () => { it("does not rewrite removed routing.transcribeAudio migrations", () => { const res = migrateLegacyConfigForTest({ diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.session.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.session.ts new file mode 100644 index 00000000000..2626eae3ec7 --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.session.ts @@ -0,0 +1,34 @@ +import { + defineLegacyConfigMigration, + getRecord, + type LegacyConfigMigrationSpec, + type LegacyConfigRule, +} from "../../../config/legacy.shared.js"; + +function hasLegacyRotateBytes(value: unknown): boolean { + const maintenance = getRecord(value); + return Boolean(maintenance && Object.prototype.hasOwnProperty.call(maintenance, "rotateBytes")); +} + +const LEGACY_SESSION_MAINTENANCE_ROTATE_BYTES_RULE: LegacyConfigRule = { + path: ["session", "maintenance"], + message: + 'session.maintenance.rotateBytes is deprecated and ignored; run "openclaw doctor --fix" to remove it.', + match: hasLegacyRotateBytes, +}; + +export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_SESSION: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "session.maintenance.rotateBytes", + describe: "Remove deprecated session.maintenance.rotateBytes", + legacyRules: [LEGACY_SESSION_MAINTENANCE_ROTATE_BYTES_RULE], + apply: (raw, changes) => { + const maintenance = getRecord(getRecord(raw.session)?.maintenance); + if (!maintenance || !Object.prototype.hasOwnProperty.call(maintenance, "rotateBytes")) { + return; + } + delete maintenance.rotateBytes; + changes.push("Removed deprecated session.maintenance.rotateBytes."); + }, + }), +]; diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts index f2e7588e6c2..3128192634b 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts @@ -3,6 +3,7 @@ import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS } from "./legacy-config-migrati import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY } from "./legacy-config-migrations.runtime.gateway.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_MCP } from "./legacy-config-migrations.runtime.mcp.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS } from "./legacy-config-migrations.runtime.providers.js"; +import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_SESSION } from "./legacy-config-migrations.runtime.session.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS } from "./legacy-config-migrations.runtime.tts.js"; export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [ @@ -10,5 +11,6 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [ ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_MCP, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS, + ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_SESSION, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS, ]; diff --git a/src/commands/sessions-cleanup.test.ts b/src/commands/sessions-cleanup.test.ts index 3b98787ce3f..8a095665c88 100644 --- a/src/commands/sessions-cleanup.test.ts +++ b/src/commands/sessions-cleanup.test.ts @@ -73,7 +73,6 @@ describe("sessionsCleanupCommand", () => { mode: "warn", pruneAfterMs: 7 * 24 * 60 * 60 * 1000, maxEntries: 500, - rotateBytes: 10_485_760, resetArchiveRetentionMs: 7 * 24 * 60 * 60 * 1000, maxDiskBytes: null, highWaterBytes: null, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 8c14f602cd5..455a437ab9a 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -20699,9 +20699,9 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { type: "number", }, ], - title: "Session Rotate Size", + title: "Deprecated Session Rotate Size", description: - "Rotates the session store when file size exceeds a threshold such as `10mb` or `1gb`. Use this to bound single-file growth and keep backup/restore operations manageable.", + 'Deprecated and ignored. Do not use for `sessions.json` growth control; OpenClaw no longer creates automatic rotation backups, and "openclaw doctor --fix" removes this key.', }, resetArchiveRetention: { anyOf: [ @@ -20750,7 +20750,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { additionalProperties: false, title: "Session Maintenance", description: - "Automatic session-store maintenance controls for pruning age, entry caps, and file rotation behavior. Start in warn mode to observe impact, then enforce once thresholds are tuned.", + "Automatic session-store maintenance controls for pruning age, entry caps, reset archive retention, and disk budget cleanup. Start in warn mode to observe impact, then enforce once thresholds are tuned.", }, }, additionalProperties: false, @@ -27524,7 +27524,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, "session.maintenance": { label: "Session Maintenance", - help: "Automatic session-store maintenance controls for pruning age, entry caps, and file rotation behavior. Start in warn mode to observe impact, then enforce once thresholds are tuned.", + help: "Automatic session-store maintenance controls for pruning age, entry caps, reset archive retention, and disk budget cleanup. Start in warn mode to observe impact, then enforce once thresholds are tuned.", tags: ["storage"], }, "session.maintenance.mode": { @@ -27548,8 +27548,8 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { tags: ["performance", "storage"], }, "session.maintenance.rotateBytes": { - label: "Session Rotate Size", - help: "Rotates the session store when file size exceeds a threshold such as `10mb` or `1gb`. Use this to bound single-file growth and keep backup/restore operations manageable.", + label: "Deprecated Session Rotate Size", + help: 'Deprecated and ignored. Do not use for `sessions.json` growth control; OpenClaw no longer creates automatic rotation backups, and "openclaw doctor --fix" removes this key.', tags: ["storage"], }, "session.maintenance.resetArchiveRetention": { diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 7285838cc95..fca17404d78 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -683,8 +683,8 @@ describe("config help copy quality", () => { expect(pruneAfter.includes("12h")).toBe(true); const rotate = FIELD_HELP["session.maintenance.rotateBytes"]; - expect(rotate.includes("10mb")).toBe(true); - expect(rotate.includes("1gb")).toBe(true); + expect(/deprecated/i.test(rotate)).toBe(true); + expect(rotate.includes("doctor --fix")).toBe(true); const deprecated = FIELD_HELP["session.maintenance.pruneDays"]; expect(/deprecated/i.test(deprecated)).toBe(true); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index ce90d63729d..d3b4553ae22 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1420,7 +1420,7 @@ export const FIELD_HELP: Record = { "session.threadBindings.maxAgeHours": "Optional hard max age in hours for thread-bound sessions across providers/channels (0 disables hard cap). Default: 0.", "session.maintenance": - "Automatic session-store maintenance controls for pruning age, entry caps, and file rotation behavior. Start in warn mode to observe impact, then enforce once thresholds are tuned.", + "Automatic session-store maintenance controls for pruning age, entry caps, reset archive retention, and disk budget cleanup. Start in warn mode to observe impact, then enforce once thresholds are tuned.", "session.maintenance.mode": 'Determines whether maintenance policies are only reported ("warn") or actively applied ("enforce"). Keep "warn" during rollout and switch to "enforce" after validating safe thresholds.', "session.maintenance.pruneAfter": @@ -1430,7 +1430,7 @@ export const FIELD_HELP: Record = { "session.maintenance.maxEntries": "Caps total session entry count retained in the store to prevent unbounded growth over time. Use lower limits for constrained environments, or higher limits when longer history is required.", "session.maintenance.rotateBytes": - "Rotates the session store when file size exceeds a threshold such as `10mb` or `1gb`. Use this to bound single-file growth and keep backup/restore operations manageable.", + 'Deprecated and ignored. Do not use for `sessions.json` growth control; OpenClaw no longer creates automatic rotation backups, and "openclaw doctor --fix" removes this key.', "session.maintenance.resetArchiveRetention": "Retention for reset transcript archives (`*.reset.`). Accepts a duration (for example `30d`), or `false` to disable cleanup. Defaults to pruneAfter so reset artifacts do not grow forever.", "session.maintenance.maxDiskBytes": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 95c1c2e5cff..4177cd861b1 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -712,7 +712,7 @@ export const FIELD_LABELS: Record = { "session.maintenance.pruneAfter": "Session Prune After", "session.maintenance.pruneDays": "Session Prune Days (Deprecated)", "session.maintenance.maxEntries": "Session Max Entries", - "session.maintenance.rotateBytes": "Session Rotate Size", + "session.maintenance.rotateBytes": "Deprecated Session Rotate Size", "session.maintenance.resetArchiveRetention": "Session Reset Archive Retention", "session.maintenance.maxDiskBytes": "Session Max Disk Budget", "session.maintenance.highWaterBytes": "Session Disk High-water Target", diff --git a/src/config/sessions/runtime-types.ts b/src/config/sessions/runtime-types.ts index 2ff64f06245..135a6da00a0 100644 --- a/src/config/sessions/runtime-types.ts +++ b/src/config/sessions/runtime-types.ts @@ -22,7 +22,6 @@ export type ResolvedSessionMaintenanceConfigRuntime = { mode: SessionMaintenanceMode; pruneAfterMs: number; maxEntries: number; - rotateBytes: number; resetArchiveRetentionMs: number | null; maxDiskBytes: number | null; highWaterBytes: number | null; diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index 5973823d621..b2d5465d3a1 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -1,5 +1,3 @@ -import fs from "node:fs"; -import path from "node:path"; import { parseByteSize } from "../../cli/parse-bytes.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -11,7 +9,6 @@ const log = createSubsystemLogger("sessions/store"); const DEFAULT_SESSION_PRUNE_AFTER_MS = 30 * 24 * 60 * 60 * 1000; const DEFAULT_SESSION_MAX_ENTRIES = 500; -const DEFAULT_SESSION_ROTATE_BYTES = 10_485_760; // 10 MB const DEFAULT_SESSION_MAINTENANCE_MODE: SessionMaintenanceMode = "enforce"; const DEFAULT_SESSION_DISK_BUDGET_HIGH_WATER_RATIO = 0.8; const STRICT_ENTRY_MAINTENANCE_MAX_ENTRIES = 49; @@ -32,7 +29,6 @@ export type ResolvedSessionMaintenanceConfig = { mode: SessionMaintenanceMode; pruneAfterMs: number; maxEntries: number; - rotateBytes: number; resetArchiveRetentionMs: number | null; maxDiskBytes: number | null; highWaterBytes: number | null; @@ -51,19 +47,6 @@ function resolvePruneAfterMs(maintenance?: SessionMaintenanceConfig): number { } } -function resolveRotateBytes(maintenance?: SessionMaintenanceConfig): number { - const raw = maintenance?.rotateBytes; - const normalized = normalizeStringifiedOptionalString(raw); - if (!normalized) { - return DEFAULT_SESSION_ROTATE_BYTES; - } - try { - return parseByteSize(normalized, { defaultUnit: "b" }); - } catch { - return DEFAULT_SESSION_ROTATE_BYTES; - } -} - function resolveResetArchiveRetentionMs( maintenance: SessionMaintenanceConfig | undefined, pruneAfterMs: number, @@ -144,7 +127,6 @@ export function resolveMaintenanceConfigFromInput( mode: maintenance?.mode ?? DEFAULT_SESSION_MAINTENANCE_MODE, pruneAfterMs, maxEntries: maintenance?.maxEntries ?? DEFAULT_SESSION_MAX_ENTRIES, - rotateBytes: resolveRotateBytes(maintenance), resetArchiveRetentionMs: resolveResetArchiveRetentionMs(maintenance, pruneAfterMs), maxDiskBytes, highWaterBytes: resolveHighWaterBytes(maintenance, maxDiskBytes), @@ -333,74 +315,3 @@ export function capEntryCount( } return toRemove.length; } - -async function getSessionFileSize(storePath: string): Promise { - try { - const stat = await fs.promises.stat(storePath); - return stat.size; - } catch { - return null; - } -} - -/** - * Rotate the sessions file if it exceeds the configured size threshold. - * Copies the current file to `sessions.json.bak.{timestamp}` and cleans up - * old rotation backups, keeping only the 3 most recent `.bak.*` files. - */ -export async function rotateSessionFile( - storePath: string, - overrideBytes?: number, -): Promise { - const maxBytes = overrideBytes ?? resolveMaintenanceConfigFromInput().rotateBytes; - - // Check current file size (file may not exist yet). - const fileSize = await getSessionFileSize(storePath); - if (fileSize == null) { - return false; - } - - if (fileSize <= maxBytes) { - return false; - } - - // Keep the live store authoritative until the caller's later atomic write succeeds. - // A rename would remove sessions.json and create a crash window where startup sees - // an empty store; a copy gives us a backup without changing the live file. - const backupPath = `${storePath}.bak.${Date.now()}`; - try { - await fs.promises.copyFile(storePath, backupPath); - log.info("backed up session store file before rotation", { - backupPath: path.basename(backupPath), - sizeBytes: fileSize, - }); - } catch (err) { - // If backup creation fails (e.g. file disappeared), skip rotation backup only. - log.warn("session store rotation backup failed", { err }); - return false; - } - - // Clean up old backups — keep only the 3 most recent .bak.* files. - try { - const dir = path.dirname(storePath); - const baseName = path.basename(storePath); - const files = await fs.promises.readdir(dir); - const backups = files - .filter((f) => f.startsWith(`${baseName}.bak.`)) - .toSorted() - .toReversed(); - - const maxBackups = 3; - if (backups.length > maxBackups) { - const toDelete = backups.slice(maxBackups); - for (const old of toDelete) { - await fs.promises.unlink(path.join(dir, old)).catch(() => undefined); - } - log.info("cleaned up old session store backups", { deleted: toDelete.length }); - } - } catch { - // Best-effort cleanup; don't fail the write. - } - - return true; -} diff --git a/src/config/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index 0ec493367d7..f0009906a70 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -30,7 +30,6 @@ const ENFORCED_MAINTENANCE_OVERRIDE = { mode: "enforce" as const, pruneAfterMs: 7 * DAY_MS, maxEntries: 500, - rotateBytes: 10_485_760, resetArchiveRetentionMs: 7 * DAY_MS, maxDiskBytes: null, highWaterBytes: null, @@ -51,7 +50,6 @@ function applyEnforcedMaintenanceConfig(mockLoadConfig: ReturnType mode: "enforce", pruneAfter: "7d", maxEntries: 500, - rotateBytes: 10_485_760, }, }, }); @@ -64,7 +62,6 @@ function applyCappedMaintenanceConfig(mockLoadConfig: ReturnType) mode: "enforce", pruneAfter: "365d", maxEntries: 1, - rotateBytes: 10_485_760, }, }, }); @@ -258,7 +255,6 @@ describe("Integration: saveSessionStore with pruning", () => { pruneAfter: "30d", resetArchiveRetention: "3d", maxEntries: 500, - rotateBytes: 10_485_760, }, }, }); @@ -291,7 +287,6 @@ describe("Integration: saveSessionStore with pruning", () => { mode: "warn", pruneAfter: "7d", maxEntries: 1, - rotateBytes: 10_485_760, }, }, }); @@ -405,7 +400,6 @@ describe("Integration: saveSessionStore with pruning", () => { mode: "enforce", pruneAfter: "365d", maxEntries: 50, - rotateBytes: 10_485_760, }, }, }); @@ -426,7 +420,6 @@ describe("Integration: saveSessionStore with pruning", () => { mode: "enforce", pruneAfter: "365d", maxEntries: 1000, - rotateBytes: 10_485_760, }, }, }); @@ -449,7 +442,6 @@ describe("Integration: saveSessionStore with pruning", () => { mode: "warn", pruneAfter: "365d", maxEntries: 1, - rotateBytes: 10_485_760, }, }, }); @@ -529,7 +521,6 @@ describe("Integration: saveSessionStore with pruning", () => { mode: "enforce", pruneAfter: "365d", maxEntries: 100, - rotateBytes: 10_485_760, maxDiskBytes: 900, highWaterBytes: 700, }, @@ -562,7 +553,6 @@ describe("Integration: saveSessionStore with pruning", () => { mode: "enforce", pruneAfter: "365d", maxEntries: 100, - rotateBytes: 10_485_760, maxDiskBytes: 900, highWaterBytes: 700, }, @@ -587,6 +577,83 @@ describe("Integration: saveSessionStore with pruning", () => { expect(loaded.newer).toBeDefined(); }); + it("does not create rotation backups for hot oversized store writes", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "365d", + maxEntries: 100, + rotateBytes: 200, + }, + }, + }); + + let now = 1_800_000_000_000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => (now += 1000)); + try { + const store: Record = { + hot: { + sessionId: "hot-session", + updatedAt: Date.now(), + pluginExtensions: { test: { payload: "x".repeat(1000) } }, + }, + }; + + for (let i = 0; i < 5; i++) { + store.hot.updatedAt = Date.now(); + store.hot.pluginExtensions = { test: { payload: "x".repeat(1000), write: i } }; + await saveSessionStore(storePath, store); + } + } finally { + nowSpy.mockRestore(); + } + + const files = await fs.readdir(testDir); + const backups = files.filter((file) => file.startsWith("sessions.json.bak.")); + expect(backups).toHaveLength(0); + }); + + it("does not create rotation backups for destructive maintenance rewrites", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "365d", + maxEntries: 1, + rotateBytes: 200, + }, + }, + }); + + const now = Date.now(); + const store: Record = { + old: { + sessionId: "old-session", + updatedAt: now - DAY_MS, + pluginExtensions: { test: { payload: "x".repeat(1000) } }, + }, + fresh: { + sessionId: "fresh-session", + updatedAt: now, + pluginExtensions: { test: { payload: "y".repeat(1000) } }, + }, + }; + await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); + + await saveSessionStore( + storePath, + JSON.parse(JSON.stringify(store)) as Record, + ); + + const files = await fs.readdir(testDir); + const backups = files.filter((file) => file.startsWith("sessions.json.bak.")); + expect(backups).toHaveLength(0); + const loaded = loadSessionStore(storePath, { skipCache: true }); + expect(loaded.old).toBeUndefined(); + expect(loaded.fresh).toBeDefined(); + }); + it("never deletes transcripts outside the agent sessions directory during budget cleanup", async () => { mockLoadConfig.mockReturnValue({ session: { @@ -594,7 +661,6 @@ describe("Integration: saveSessionStore with pruning", () => { mode: "enforce", pruneAfter: "365d", maxEntries: 100, - rotateBytes: 10_485_760, maxDiskBytes: 500, highWaterBytes: 300, }, diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index eaee2c9f6ef..c069f799453 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -1,19 +1,11 @@ import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createFixtureSuite } from "../../test-utils/fixture-suite.js"; import { resolveMaintenanceConfigFromInput, resolveSessionEntryMaintenanceHighWater, } from "./store-maintenance.js"; -import { - capEntryCount, - getActiveSessionMaintenanceWarning, - loadSessionStore, - pruneStaleEntries, - rotateSessionFile, -} from "./store.js"; +import { capEntryCount, getActiveSessionMaintenanceWarning, pruneStaleEntries } from "./store.js"; import type { SessionEntry } from "./types.js"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -135,90 +127,3 @@ describe("getActiveSessionMaintenanceWarning", () => { expect(warning?.wouldCap).toBe(true); }); }); - -describe("rotateSessionFile", () => { - let testDir: string; - let storePath: string; - - beforeEach(async () => { - testDir = await fixtureSuite.createCaseDir("rotate"); - storePath = path.join(testDir, "sessions.json"); - }); - - it("file over maxBytes: copies to .bak.{timestamp}, returns true", async () => { - const bigContent = "x".repeat(200); - await fs.writeFile(storePath, bigContent, "utf-8"); - - const rotated = await rotateSessionFile(storePath, 100); - - expect(rotated).toBe(true); - await expect(fs.readFile(storePath, "utf-8")).resolves.toBe(bigContent); - const files = await fs.readdir(testDir); - const bakFiles = files.filter((f) => f.startsWith("sessions.json.bak.")); - expect(bakFiles).toHaveLength(1); - const bakContent = await fs.readFile(path.join(testDir, bakFiles[0]), "utf-8"); - expect(bakContent).toBe(bigContent); - }); - - it("keeps live sessions readable if rotation is interrupted before the final save", async () => { - const store = makeStore([["group:telegram:1", makeEntry(Date.now())]]); - await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); - - const rotated = await rotateSessionFile(storePath, 10); - const loaded = loadSessionStore(storePath, { - skipCache: true, - maintenanceConfig: { - mode: "enforce", - pruneAfterMs: DAY_MS, - maxEntries: 100, - rotateBytes: 1024 * 1024, - resetArchiveRetentionMs: null, - maxDiskBytes: null, - highWaterBytes: null, - }, - }); - - expect(rotated).toBe(true); - expect(loaded["group:telegram:1"]?.sessionId).toBe(store["group:telegram:1"].sessionId); - }); - - it("keeps an empty live store authoritative when stale backups exist", async () => { - const staleStore = makeStore([["stale", makeEntry(Date.now())]]); - await fs.writeFile(`${storePath}.bak.${Date.now()}`, JSON.stringify(staleStore), "utf-8"); - await fs.writeFile(storePath, "{}", "utf-8"); - - const loaded = loadSessionStore(storePath, { - skipCache: true, - maintenanceConfig: { - mode: "enforce", - pruneAfterMs: DAY_MS, - maxEntries: 100, - rotateBytes: 1024 * 1024, - resetArchiveRetentionMs: null, - maxDiskBytes: null, - highWaterBytes: null, - }, - }); - - expect(loaded).toEqual({}); - }); - - it("multiple rotations: only keeps 3 most recent .bak files", async () => { - let now = Date.now(); - const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => (now += 5)); - try { - // 4 rotations are enough to verify pruning to <=3 backups. - for (let i = 0; i < 4; i++) { - await fs.writeFile(storePath, `data-${i}-${"x".repeat(100)}`, "utf-8"); - await rotateSessionFile(storePath, 50); - } - } finally { - nowSpy.mockRestore(); - } - - const files = await fs.readdir(testDir); - const bakFiles = files.filter((f) => f.startsWith("sessions.json.bak.")).toSorted(); - - expect(bakFiles.length).toBeLessThanOrEqual(3); - }); -}); diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 149a8a20cd7..7456c85495a 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -39,7 +39,6 @@ import { capEntryCount, getActiveSessionMaintenanceWarning, pruneStaleEntries, - rotateSessionFile, shouldRunSessionEntryMaintenance, type ResolvedSessionMaintenanceConfig, type SessionMaintenanceWarning, @@ -134,7 +133,6 @@ export { getActiveSessionMaintenanceWarning, pruneStaleEntries, resolveMaintenanceConfig, - rotateSessionFile, }; export type { ResolvedSessionMaintenanceConfig, SessionMaintenanceWarning }; @@ -364,9 +362,6 @@ async function saveSessionStoreUnlocked( } } - // Rotate the on-disk file if it exceeds the size threshold. - await rotateSessionFile(storePath, maintenance.rotateBytes); - const diskBudget = await enforceSessionDiskBudget({ store, storePath, diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 3d23887c532..d96005461d3 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -184,7 +184,7 @@ export type SessionConfig = { }; /** Shared defaults for thread-bound session routing across channels/providers. */ threadBindings?: SessionThreadBindingsConfig; - /** Automatic session store maintenance (pruning, capping, file rotation). */ + /** Automatic session store maintenance (pruning, capping, archive retention, disk budget). */ maintenance?: SessionMaintenanceConfig; }; @@ -199,7 +199,7 @@ export type SessionMaintenanceConfig = { pruneDays?: number; /** Maximum number of session entries to keep. Default: 500. */ maxEntries?: number; - /** Rotate sessions.json when it exceeds this size (e.g. "10mb"). Default: 10mb. */ + /** Deprecated and ignored. Run `openclaw doctor --fix` to remove. */ rotateBytes?: number | string; /** * Retention for archived reset transcripts (`*.reset.`). diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index a6f657dc004..a1cf4b96ca4 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -97,19 +97,6 @@ export const SessionSchema = z }); } } - if (val.rotateBytes !== undefined) { - try { - parseByteSize(normalizeStringifiedOptionalString(val.rotateBytes) ?? "", { - defaultUnit: "b", - }); - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["rotateBytes"], - message: "invalid size (use b, kb, mb, gb, tb)", - }); - } - } if (val.resetArchiveRetention !== undefined && val.resetArchiveRetention !== false) { try { parseDurationMs(normalizeStringifiedOptionalString(val.resetArchiveRetention) ?? "", {