diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md
index 015bd2370590..49f4387c5e21 100644
--- a/docs/automation/cron-jobs.md
+++ b/docs/automation/cron-jobs.md
@@ -799,7 +799,7 @@ Disable automations: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
- `cron.sessionRetention` (default `24h`, `false` disables) prunes isolated run-session entries. Run history keeps the newest 2000 terminal rows per job; lost rows retain their 24-hour cleanup window.
+ `cron.sessionRetention` (default `24h`, `false` or `"0h"` disables) prunes isolated run-session entries. Run history keeps the newest 2000 terminal rows per job; lost rows retain their 24-hour cleanup window.
On upgrade, run `openclaw doctor --fix` to import historical `~/.openclaw/cron/jobs.json`, `jobs-state.json`, `jobs-quarantine.json`, and `runs/*.jsonl` files into SQLite and archive the originals with a `.migrated` suffix. Malformed job rows remain recoverable in SQLite while valid jobs keep running.
diff --git a/docs/cli/cron.md b/docs/cli/cron.md
index 69c656c17f8e..7f447533d1d0 100644
--- a/docs/cli/cron.md
+++ b/docs/cli/cron.md
@@ -216,7 +216,7 @@ The scheduler does not classify final-output prose or approval-looking refusal p
Retention behavior:
-- `cron.sessionRetention` (default `24h`, or `false` to disable) prunes completed isolated run sessions.
+- `cron.sessionRetention` (default `24h`, or `false` to disable; a zero duration such as `"0h"` also disables) prunes completed isolated run sessions.
- Run history keeps the newest 2000 terminal rows per job. Lost rows retain the standard 24-hour lost-task cleanup window.
## Migrating older jobs
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index eddd312d8f28..6ae2aadad842 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -1470,14 +1470,14 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
webhookSsrfPolicy: {
allowedHostnames: ["127.0.0.1"], // optional exact exception for a trusted receiver
},
- sessionRetention: "24h", // duration string or false
+ sessionRetention: "24h", // duration string ("0h" disables) or false
},
}
```
- `enabled`: execute stored automation jobs (default: `true`). Set `false` to pause all automation execution without deleting jobs.
- `triggers.enabled`: also run event-driven automation triggers (default: `false`).
-- `sessionRetention`: how long to keep completed isolated automation run sessions before pruning SQLite session rows. Also controls cleanup of archived deleted automation transcripts. Default: `24h`; set `false` to disable.
+- `sessionRetention`: how long to keep completed isolated automation run sessions before pruning SQLite session rows. Also controls cleanup of archived deleted automation transcripts. Default: `24h`; set `false` or a zero duration such as `"0h"` to disable (negative durations are invalid).
- Run history automatically keeps the newest 2000 terminal rows per job. Lost rows retain their 24-hour cleanup window.
- `webhookToken`: bearer token used for automation webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent.
- `webhookSsrfPolicy`: shared outbound SSRF policy for primary, completion, failure-destination, and failure-alert webhooks. Private/internal targets are blocked when omitted. Prefer exact `allowedHostnames`; use `dangerouslyAllowPrivateNetwork: true` only for trusted private-network receivers. The narrow fake-IP proxy flags are `allowRfc2544BenchmarkRange` and `allowIpv6UniqueLocalRange`.
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index d594a712d7f2..495105186ae2 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -414,7 +414,7 @@ candidate contains a redacted secret placeholder such as `***` or `[redacted]`.
}
```
- - `sessionRetention`: prune completed isolated run sessions from SQLite session rows (default `24h`; set `false` to disable).
+ - `sessionRetention`: prune completed isolated run sessions from SQLite session rows (default `24h`; set `false` or a zero duration such as `"0h"` to disable).
- Run history automatically keeps the newest 2000 terminal rows per job; lost rows retain their 24-hour cleanup window.
- See [Cron jobs](/automation/cron-jobs) for feature overview and CLI examples.
diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md
index cca70b86e756..c41242244c81 100644
--- a/docs/reference/session-management-compaction.md
+++ b/docs/reference/session-management-compaction.md
@@ -108,7 +108,7 @@ artifacts before importing.
Isolated cron runs create their own session entries/transcripts with dedicated retention:
-- `cron.sessionRetention` (default `"24h"`) prunes old isolated cron run sessions from the store; `false` disables.
+- `cron.sessionRetention` (default `"24h"`) prunes old isolated cron run sessions from the store; `false` or a zero duration such as `"0h"` disables.
- Run history keeps the newest 2000 terminal rows per cron job. Lost rows retain their 24-hour cleanup window.
When cron force-creates a new isolated run session, it sanitizes the previous `cron:` session entry before writing the new row: it carries safe preferences (thinking/fast/verbose/reasoning settings, labels, display name) and explicit user-selected model/auth overrides, but drops ambient conversation context (channel/group routing, send/queue policy, elevation, origin, ACP runtime binding) so a fresh isolated run cannot inherit stale delivery or runtime authority from an older run.
diff --git a/src/config/schema.help.automation.ts b/src/config/schema.help.automation.ts
index d678817b3ca6..8562afcd11fe 100644
--- a/src/config/schema.help.automation.ts
+++ b/src/config/schema.help.automation.ts
@@ -100,7 +100,7 @@ export const AUTOMATION_FIELD_HELP: Record = {
"cron.webhookSsrfPolicy.allowIpv6UniqueLocalRange":
"Allows automation webhooks to IPv6 Unique Local Addresses (fc00::/7). Use only with trusted fake-IP proxy environments.",
"cron.sessionRetention":
- "Controls how long completed automation run sessions are kept before pruning (`24h`, `7d`, `1h30m`, or `false` to disable pruning; default: `24h`). Use shorter retention to reduce storage growth on high-frequency schedules.",
+ "Controls how long completed automation run sessions are kept before pruning (`24h`, `7d`, `1h30m`, or `false` to disable pruning; a zero duration such as `0h` also disables; default: `24h`). Use shorter retention to reduce storage growth on high-frequency schedules.",
transcripts:
"Core transcript capture settings for meeting notes, recording-capable agent tools, and configured live meeting auto-start sources. Meeting plugins capture durable notes by default; set enabled to false to opt out globally.",
"transcripts.enabled":
diff --git a/src/config/types.cron.ts b/src/config/types.cron.ts
index 7ec48b05f9dd..e7e2b208c713 100644
--- a/src/config/types.cron.ts
+++ b/src/config/types.cron.ts
@@ -32,6 +32,7 @@ export type CronConfig = {
/**
* How long to retain completed cron run sessions before automatic pruning.
* Accepts a duration string (e.g. "24h", "7d", "1h30m") or `false` to disable pruning.
+ * A zero duration (e.g. "0h") also disables pruning; negative durations are invalid.
* Default: "24h".
*/
sessionRetention?: string | false;
diff --git a/src/cron/session-reaper.test.ts b/src/cron/session-reaper.test.ts
index bbfd33cc02bf..0f1af6fbd5fb 100644
--- a/src/cron/session-reaper.test.ts
+++ b/src/cron/session-reaper.test.ts
@@ -527,6 +527,32 @@ describe("sweepCronRunSessions", () => {
expect(result.pruned).toBe(0);
});
+ it.each([["0h"], ["0s"], ["0"]])(
+ "treats a zero retention (%s) as disabled instead of pruning everything",
+ async (sessionRetention) => {
+ const now = Date.now();
+ const store: Record = {
+ "agent:main:cron:job1:run:run1": {
+ sessionId: "run1",
+ updatedAt: now - 100 * 3_600_000,
+ },
+ };
+ await seedSessionEntries(storePath, store);
+
+ const result = await sweepCronRunSessions({
+ cronConfig: { sessionRetention },
+ sessionStorePath: storePath,
+ nowMs: now,
+ log,
+ force: true,
+ });
+
+ expect(result.swept).toBe(false);
+ expect(result.pruned).toBe(0);
+ expect(readSessionEntries(storePath)).toHaveProperty("agent:main:cron:job1:run:run1");
+ },
+ );
+
it("sweeps immediately when disabled retention is enabled again", async () => {
const now = Date.now();
const sessionKey = "agent:main:cron:job1:run:expired-run";
diff --git a/src/cron/session-reaper.ts b/src/cron/session-reaper.ts
index 697b3bb69207..1062842db4fc 100644
--- a/src/cron/session-reaper.ts
+++ b/src/cron/session-reaper.ts
@@ -33,7 +33,15 @@ function resolveRetentionMs(cronConfig?: CronConfig): number | null {
const raw = cronConfig?.sessionRetention;
if (typeof raw === "string" && raw.trim()) {
try {
- return parseDurationMs(raw.trim(), { defaultUnit: "h" });
+ const ms = parseDurationMs(raw.trim(), { defaultUnit: "h" });
+ // A zero retention ("0h") is a disable signal, not "prune everything":
+ // cutoff would equal now and the next sweep would delete every cron run
+ // session. Negative durations never get here (the parser rejects them);
+ // the <= 0 check stays defensive.
+ if (ms <= 0) {
+ return null;
+ }
+ return ms;
} catch {
return DEFAULT_RETENTION_MS;
}