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 <g515hoshino@gmail.com>

* 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 <g515hoshino@gmail.com>

* 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 <steipete@gmail.com>
Co-authored-by: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
Masato Hoshino
2026-08-13 12:05:46 +09:00
committed by GitHub
parent 1e19f22dbd
commit 4c12c973ed
8 changed files with 90 additions and 22 deletions
+1 -1
View File
@@ -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)
@@ -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.<timestamp>.zst` when zstd is available) before removing the deleted session's rows.
+1 -1
View File
@@ -83,7 +83,7 @@ export const AUTOMATION_FIELD_HELP: Record<string, string> = {
"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.",
+42
View File
@@ -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<void> {
@@ -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<string, SessionEntry> = {};
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", () => {
+9 -18
View File
@@ -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;
}
}
+24
View File
@@ -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);
+2 -1
View File
@@ -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;
};
@@ -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,