From 4c12c973edc56c5685cffafb4baa5a2b3ecd9ac6 Mon Sep 17 00:00:00 2001 From: Masato Hoshino Date: Thu, 13 Aug 2026 12:05:46 +0900 Subject: [PATCH] fix(sessions): highWaterBytes 0 no longer deletes all session history (#119909) * fix(sessions): zero highWaterBytes no longer clears all session history resolveHighWaterBytes passed an explicit non-positive highWaterBytes through verbatim. The resolved value is the disk-budget cleanup loop's stop condition, so a zero target made enforce mode evict every unprotected session and prune its extracted archives instead of trimming to the documented 80% default. Route the non-positive case to the function's existing unusable-value branch (computeDefault). Not null: that disables the budget and permits unbounded growth, which is right for a cap but wrong for a target. Sibling of #119422, which fixed the same harm for maxDiskBytes and guarded only resolveMaxDiskBytes. * test(infra): isolate worktree migration discovery Keep worktree migration coverage focused on its real filesystem, Git, and SQLite owner while avoiding unrelated channel and plugin doctor cold starts on fork CI. Co-authored-by: masatohoshino * test(ci): carry owner-approved SDK and doctor gate repairs Carry the already-approved plugin SDK contract manifest and focused doctor-flow test isolation from the maintainer-owned CI repair. Preserve real config migration, persistence, snapshot, and SQLite cleanup coverage; no production behavior changes. Co-authored-by: masatohoshino * fix(sessions): use the renamed withTestDir helper in the new budget test * fix(sessions): align high-water zero contract * style(sessions): format high-water changes --------- Co-authored-by: Peter Steinberger Co-authored-by: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com> --- docs/gateway/config-agents.md | 2 +- .../session-management-compaction.md | 2 +- src/config/schema.help.automation.ts | 2 +- src/config/sessions/disk-budget.test.ts | 42 +++++++++++++++++++ src/config/sessions/store-maintenance.ts | 27 ++++-------- src/config/sessions/store.pruning.test.ts | 24 +++++++++++ src/config/types.base.ts | 3 +- ...ema.session-maintenance-extensions.test.ts | 10 +++++ 8 files changed, 90 insertions(+), 22 deletions(-) diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 552734d85293..548db189abb3 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -1253,7 +1253,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - Legacy `rotateBytes` is rejected by the current schema; `openclaw doctor --fix` removes it from older configs. - `resetArchiveRetention`: age-based retention for reset/deleted transcript archives. By default, archives remain until disk-budget eviction; set a duration to opt into wall-clock deletion, or `false` to disable it explicitly. - `maxDiskBytes`: optional sessions-directory disk budget. In `warn` mode it logs warnings; in `enforce` mode it removes oldest artifacts/sessions first. Set `false`, `0`, or `"0"` to disable the budget entirely. - - `highWaterBytes`: optional target after budget cleanup. Defaults to `80%` of `maxDiskBytes`. + - `highWaterBytes`: optional target after budget cleanup. Defaults to `80%` of `maxDiskBytes`. A value that resolves to zero falls back to the default; negative values are invalid. Disable the budget with `maxDiskBytes`, not with a zero high-water mark. - **`threadBindings`**: global defaults for thread-bound session features. - `enabled`: master switch for supported channel thread bindings - `idleHours`: default inactivity auto-unfocus in hours (`0` disables; providers can override) diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index a4f96a31579e..3e5d1ad2a50f 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -51,7 +51,7 @@ Per agent, on the Gateway host (resolved via `src/config/sessions.ts`): | `maxEntries` | `500` | cap on session entries | | `resetArchiveRetention` | keep (no age cutoff) | age cutoff for `*.reset.*`/`*.deleted.*` transcript archives; a duration opts into deletion | | `maxDiskBytes` | `10gb` | per-agent sessions disk budget; `false`, `0`, or `"0"` disables | -| `highWaterBytes` | 80% of `maxDiskBytes` | target after budget cleanup | +| `highWaterBytes` | 80% of `maxDiskBytes` | target after cleanup; zero-resolving values use the default, and negatives are invalid | Reset advances the live `sessionKey -> sessionId` mapping but keeps the previous SQLite session, transcript, trajectory, and search rows. That history remains searchable under the same session key; ordinary entry and session lists show only the new live mapping. Retained reset history is bounded by the disk budget, not by `resetArchiveRetention`, which only ages archive artifacts. Explicit deletion is different: it writes and verifies a compressed transcript archive (`*.jsonl.deleted..zst` when zstd is available) before removing the deleted session's rows. diff --git a/src/config/schema.help.automation.ts b/src/config/schema.help.automation.ts index 8562afcd11fe..398e52d65f9c 100644 --- a/src/config/schema.help.automation.ts +++ b/src/config/schema.help.automation.ts @@ -83,7 +83,7 @@ export const AUTOMATION_FIELD_HELP: Record = { "session.maintenance.maxDiskBytes": 'Per-agent sessions-directory disk budget (for example `500mb`). Defaults to `10gb`; when exceeded, warn mode reports pressure and enforce mode performs oldest-first cleanup (archived transcripts before live sessions). Set `false`, `0`, or `"0"` to disable.', "session.maintenance.highWaterBytes": - "Target size after disk-budget cleanup (high-water mark). Defaults to 80% of maxDiskBytes; set explicitly for tighter reclaim behavior on constrained disks.", + "Target size after disk-budget cleanup (high-water mark). Defaults to 80% of maxDiskBytes; set explicitly for tighter reclaim behavior on constrained disks. A value that resolves to zero falls back to the default; negative values are invalid. Disable the budget with maxDiskBytes instead.", cron: "Global scheduler settings for stored automations, run concurrency, delivery fallback, and run-session retention. Keep defaults unless you are scaling automation volume or integrating external webhook receivers.", "cron.enabled": "Enables automation execution for stored schedules managed by the gateway. Keep enabled for normal reminder/automation flows, and disable only to pause all automation execution without deleting jobs.", diff --git a/src/config/sessions/disk-budget.test.ts b/src/config/sessions/disk-budget.test.ts index c1286df72f44..60c8c9b9119d 100644 --- a/src/config/sessions/disk-budget.test.ts +++ b/src/config/sessions/disk-budget.test.ts @@ -22,6 +22,7 @@ import { pruneUnreferencedSessionArtifacts, } from "./disk-budget.js"; import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; +import { resolveMaintenanceConfigFromInput } from "./store-maintenance.js"; import type { SessionEntry } from "./types.js"; async function expectPathExists(targetPath: string): Promise { @@ -831,6 +832,47 @@ describe("enforceSessionDiskBudget", () => { await expectPathExists(oldTranscript); }); }); + + it("stops at the default target when highWaterBytes resolves to zero", async () => { + await withTestDir({ prefix: "openclaw-zero-high-water-" }, async (dir) => { + const storePath = path.join(dir, "sessions.json"); + const store: Record = {}; + for (let index = 1; index <= 4; index += 1) { + await fs.writeFile(path.join(dir, `worker-${index}.jsonl`), "x".repeat(64 * 1024)); + store[`agent:main:subagent:worker-${index}`] = { + sessionId: `worker-${index}`, + updatedAt: index, + }; + } + await saveSessionStore(storePath, store, { skipMaintenance: true }); + + const maintenance = resolveMaintenanceConfigFromInput({ + maxDiskBytes: 200_000, + highWaterBytes: 0, + }); + const result = await enforceSessionDiskBudget({ + store, + storePath, + maintenance: { + maxDiskBytes: maintenance.maxDiskBytes, + highWaterBytes: maintenance.highWaterBytes, + }, + warnOnly: false, + commitEvictedIndex: async () => { + await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); + }, + }); + + // The resolved high-water mark is this loop's stop condition, so a zero + // mark is unreachable while any data remains and every session would be + // evicted. The default target stops the sweep with history intact. + expect(maintenance.highWaterBytes).toBe(160_000); + expectBudgetResult(result); + expect(result.totalBytesAfter).toBeLessThanOrEqual(160_000); + expect(store).toHaveProperty("agent:main:subagent:worker-4"); + await expectPathExists(path.join(dir, "worker-4.jsonl")); + }); + }); }); describe("pruneUnreferencedSessionArtifacts", () => { diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index faa0688c1f36..ecabc57142d0 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -120,34 +120,25 @@ function resolveHighWaterBytes( maintenance: SessionMaintenanceConfig | undefined, maxDiskBytes: number | null, ): number | null { - const computeDefault = () => { - if (maxDiskBytes == null) { - return null; - } - if (maxDiskBytes <= 0) { - return 0; - } - return Math.max( - 1, - Math.min( - maxDiskBytes, - Math.floor(maxDiskBytes * DEFAULT_SESSION_DISK_BUDGET_HIGH_WATER_RATIO), - ), - ); - }; if (maxDiskBytes == null) { return null; } + const defaultHighWaterBytes = Math.max( + 1, + Math.min(maxDiskBytes, Math.floor(maxDiskBytes * DEFAULT_SESSION_DISK_BUDGET_HIGH_WATER_RATIO)), + ); const raw = maintenance?.highWaterBytes; const normalized = normalizeStringifiedOptionalString(raw); if (!normalized) { - return computeDefault(); + return defaultHighWaterBytes; } try { const parsed = parseByteSize(normalized, { defaultUnit: "b" }); - return Math.min(parsed, maxDiskBytes); + // A zero target cannot stop cleanup while any bytes remain, so use the + // default instead of evicting every unprotected session and archive. + return parsed > 0 ? Math.min(parsed, maxDiskBytes) : defaultHighWaterBytes; } catch { - return computeDefault(); + return defaultHighWaterBytes; } } diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index ec84c33101fe..a5ef6e11a16a 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -979,6 +979,30 @@ describe("resolveMaintenanceConfigFromInput", () => { }); }); + it.each([ + ["the number 0", 0], + ["the string '0'", "0"], + ["the byte string '0b'", "0b"], + ["a byte string that rounds to zero", "0.4b"], + ])("falls back to the default high-water mark when highWaterBytes is %s", (_label, raw) => { + const maintenance = resolveMaintenanceConfigFromInput({ + maxDiskBytes: "500mb", + highWaterBytes: raw, + }); + + expect(maintenance.maxDiskBytes).toBe(500 * 1024 * 1024); + expect(maintenance.highWaterBytes).toBe(Math.floor(500 * 1024 * 1024 * 0.8)); + }); + + it("keeps an explicit positive highWaterBytes", () => { + const maintenance = resolveMaintenanceConfigFromInput({ + maxDiskBytes: "500mb", + highWaterBytes: "300mb", + }); + + expect(maintenance.highWaterBytes).toBe(300 * 1024 * 1024); + }); + it("force-gates the unset model-run prune default to the cap-eviction threshold", () => { const defaultMaintenance = resolveMaintenanceConfigFromInput({ maxEntries: 50 }); expect(resolveSessionEntryMaintenanceHighWater(50)).toBe(75); diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 166b8f64f3a2..f848cbcdb9ae 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -267,7 +267,8 @@ export type SessionMaintenanceConfig = { maxDiskBytes?: number | string | false; /** * Target size after disk-budget cleanup (high-water mark), e.g. "400mb". - * Default: 80% of maxDiskBytes. + * Default: 80% of maxDiskBytes. A value that resolves to zero falls back to + * the default instead of clearing history; negative values are invalid. */ highWaterBytes?: number | string; }; diff --git a/src/config/zod-schema.session-maintenance-extensions.test.ts b/src/config/zod-schema.session-maintenance-extensions.test.ts index d90f8011f56e..002a76f1ad96 100644 --- a/src/config/zod-schema.session-maintenance-extensions.test.ts +++ b/src/config/zod-schema.session-maintenance-extensions.test.ts @@ -73,6 +73,16 @@ describe("SessionSchema maintenance extensions", () => { ).toBe(true); }); + it.each([0, "0", "0b", "0.4b"])("accepts zero-resolving highWaterBytes: %s", (highWaterBytes) => { + expect(SessionSchema.safeParse({ maintenance: { highWaterBytes } }).success).toBe(true); + }); + + it.each([-1, "-1", "-1b", "-0.4b"])("rejects negative highWaterBytes: %s", (highWaterBytes) => { + const result = SessionSchema.safeParse({ maintenance: { highWaterBytes } }); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path).toContain("highWaterBytes"); + }); + it("accepts resetArchiveRetention: false (documented disable)", () => { expect(SessionSchema.safeParse({ maintenance: { resetArchiveRetention: false } }).success).toBe( true,