mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:30:43 +00:00
fix(sessions): remove session store rotation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.<timestamp>` 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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.<timestamp>` 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.
|
||||
|
||||
@@ -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.<id>.threadBindings.ttlHours");
|
||||
expect(legacyMessages).toContain("channels.<id>.threadBindings.idleHours");
|
||||
expect(legacyMessages).toContain("talk:");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.");
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1420,7 +1420,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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.<timestamp>`). Accepts a duration (for example `30d`), or `false` to disable cleanup. Defaults to pruneAfter so reset artifacts do not grow forever.",
|
||||
"session.maintenance.maxDiskBytes":
|
||||
|
||||
@@ -712,7 +712,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -22,7 +22,6 @@ export type ResolvedSessionMaintenanceConfigRuntime = {
|
||||
mode: SessionMaintenanceMode;
|
||||
pruneAfterMs: number;
|
||||
maxEntries: number;
|
||||
rotateBytes: number;
|
||||
resetArchiveRetentionMs: number | null;
|
||||
maxDiskBytes: number | null;
|
||||
highWaterBytes: number | null;
|
||||
|
||||
@@ -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<number | null> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<typeof vi.fn>
|
||||
mode: "enforce",
|
||||
pruneAfter: "7d",
|
||||
maxEntries: 500,
|
||||
rotateBytes: 10_485_760,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -64,7 +62,6 @@ function applyCappedMaintenanceConfig(mockLoadConfig: ReturnType<typeof vi.fn>)
|
||||
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<string, SessionEntry> = {
|
||||
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<string, SessionEntry> = {
|
||||
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<string, SessionEntry>,
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.<timestamp>`).
|
||||
|
||||
@@ -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) ?? "", {
|
||||
|
||||
Reference in New Issue
Block a user