mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user