refactor: retire legacy session store config

This commit is contained in:
Peter Steinberger
2026-05-08 08:46:26 +01:00
parent f91d9769e8
commit 90b405ecb5
12 changed files with 160 additions and 325 deletions

View File

@@ -139,9 +139,9 @@ explicit `--store` path, but per-agent runtime metadata should go through the
shared state database. Startup does not import or rewrite legacy session
indexes.
Gateway and ACP session discovery also scans disk-backed agent stores under the
default `agents/` root and under templated `session.store` roots. Discovered
stores must stay inside that resolved agent root. Symlinks and out-of-root paths
Gateway and ACP session discovery reads per-agent SQLite metadata and scans
disk-backed transcripts under the default `agents/` root. Discovered transcript
files must stay inside that resolved agent root. Symlinks and out-of-root paths
are ignored.
## WebChat behavior

View File

@@ -585,7 +585,7 @@ Periodic heartbeat runs.
midTurnPrecheck: { enabled: false }, // optional Pi tool-loop pressure check
postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection
model: "openrouter/anthropic/claude-sonnet-4-6", // optional compaction-only model override
truncateAfterCompaction: true, // rotate to a smaller successor JSONL after compaction
truncateAfterCompaction: true, // rotate to a smaller successor SQLite transcript after compaction
maxActiveTranscriptBytes: "20mb", // optional preflight local compaction trigger
notifyUser: true, // send brief notices when compaction starts and completes (default: false)
memoryFlush: {
@@ -611,7 +611,7 @@ Periodic heartbeat runs.
- `midTurnPrecheck`: optional Pi tool-loop pressure check. When `enabled: true`, OpenClaw checks context pressure after tool results are appended and before the next model call. If the context no longer fits, it aborts the current attempt before submitting the prompt and reuses the existing precheck recovery path to truncate tool results or compact and retry. Works with both `default` and `safeguard` compaction modes. Default: disabled.
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback.
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
- `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when the active JSONL grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`.
- `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when the active SQLite transcript grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`.
- `notifyUser`: when `true`, sends brief notices to the user when compaction starts and when it completes (for example, "Compacting context..." and "Compaction complete"). Disabled by default to keep compaction silent.
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Set `model` to an exact provider/model such as `ollama/qwen3:8b` when this housekeeping turn should stay on a local model; the override does not inherit the active session fallback chain. Skipped when workspace is read-only.
@@ -1180,12 +1180,6 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
group: { mode: "idle", idleMinutes: 120 },
},
resetTriggers: ["/new", "/reset"],
store: "~/.openclaw/agents/{agentId}/sessions/sessions.json", // optional legacy/custom JSON store override
maintenance: {
mode: "warn", // warn | enforce
pruneAfter: "30d",
maxEntries: 500,
},
threadBindings: {
enabled: true,
idleHours: 24, // default inactivity auto-unfocus in hours (`0` disables)
@@ -1217,14 +1211,6 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- **`mainKey`**: legacy field. Runtime always uses `"main"` for the main direct-chat bucket.
- **`agentToAgent.maxPingPongTurns`**: maximum reply-back turns between agents during agent-to-agent exchanges (integer, range: `0``5`). `0` disables ping-pong chaining.
- **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins.
- **`store`**: optional legacy/custom JSON store path. When omitted, the canonical per-agent session store uses `~/.openclaw/state/openclaw.sqlite`.
- **`maintenance`**: explicit SQLite session-row cleanup + retention controls.
- `mode`: `warn` emits warnings only; `enforce` applies cleanup.
- `pruneAfter`: age cutoff for stale entries (default `30d`).
- `maxEntries`: maximum number of entries in the session store (default `500`). Runtime writes do not prune or cap entries; `openclaw sessions cleanup --enforce` applies the cap immediately.
- `rotateBytes`: deprecated and ignored; `openclaw doctor --fix` removes it from older configs.
- `maxDiskBytes`: deprecated and ignored; session transcripts are stored in SQLite.
- `highWaterBytes`: deprecated and ignored with `maxDiskBytes`.
- **`threadBindings`**: global defaults for thread-bound session features.
- `enabled`: master default switch (providers can override; Discord uses `channels.discord.threadBindings.enabled`)
- `idleHours`: default inactivity auto-unfocus in hours (`0` disables; providers can override)

View File

@@ -85,45 +85,39 @@ OpenClaw resolves these via `src/config/sessions/*`.
---
## Store Maintenance
## Store Cleanup
Session persistence has explicit maintenance controls (`session.maintenance`) for SQLite session rows:
SQLite is the canonical per-agent session backend. `sessions.json` is a legacy
doctor-import input, not a parallel export/debug store. Runtime code should
read and write explicit `{ agentId, sessionKey }` rows.
- `mode`: `warn` (default) or `enforce`
- `pruneAfter`: stale-entry age cutoff (default `30d`)
- `maxEntries`: cap entries in the session store (default `500`)
- `maxDiskBytes`: deprecated and ignored
- `highWaterBytes`: deprecated and ignored
Runtime writes normalize and persist only; they do not prune, cap, import,
archive, or run disk-budget cleanup. Session store reads also do not import,
prune, or cap entries during Gateway startup. Use `openclaw doctor --fix` for
legacy JSON/JSONL import.
Normal Gateway writes flow through a per-store session writer that serializes in-process mutations. SQLite is the canonical per-agent backend; `sessions.json` is a legacy doctor-import input, not a parallel export/debug store. Runtime code should prefer `updateSessionStore(...)` or `updateSessionStoreEntry(...)`. Runtime writes normalize and persist only; they do not prune, cap, import, archive, or run disk-budget cleanup. When a Gateway is reachable, non-dry-run `openclaw sessions cleanup` and `openclaw agents delete` delegate store mutations to the Gateway so cleanup joins the same writer queue. Session store reads do not import, prune, or cap entries during Gateway startup; use `openclaw doctor --fix` for legacy JSON import and `openclaw sessions cleanup --enforce` for row cleanup. `openclaw sessions cleanup --enforce` applies the configured age/count policy immediately. Compaction checkpoint cleanup removes SQLite snapshot rows, not file artifacts.
Maintenance keeps durable external conversation pointers such as group sessions
and thread-scoped chat sessions, but synthetic runtime entries for cron, hooks,
heartbeat, ACP, and sub-agents can still be removed when they exceed the
configured age or count.
OpenClaw no longer creates automatic `sessions.json.bak.*` rotation backups during Gateway writes. The legacy `session.maintenance.rotateBytes`, `maxDiskBytes`, and `highWaterBytes` keys are ignored and `openclaw doctor --fix` removes them from older configs.
OpenClaw no longer creates automatic `sessions.json.bak.*` rotation backups
during Gateway writes. Legacy `session.maintenance.*` and `session.writeLock.*`
settings are doctor-migrated raw config only, and `openclaw doctor --fix`
removes them from older configs.
Transcript mutations are serialized through SQLite transactions plus the
per-session append queue. The legacy `session.writeLock.acquireTimeoutMs`
setting remains for older import/debug paths that still touch JSONL files.
per-session append queue. Runtime bootstrap and manual compaction repair write
SQLite transcript rows directly; retained `.jsonl` paths are lookup/export
metadata only.
In `mode: "warn"`, OpenClaw reports potential row pruning/capping but does not mutate the store.
Run maintenance on demand:
```bash
openclaw sessions cleanup --dry-run
openclaw sessions cleanup --enforce
```
Legacy session import belongs to `openclaw doctor --fix`. Runtime no longer has
a session cleanup command that prunes missing transcript rows; after doctor
runs, reset or delete any intentionally stale session explicitly.
---
## Cron sessions and run logs
Isolated cron runs also create session entries/transcripts. Session rows use the same explicit session cleanup path as other rows:
Isolated cron runs also create session entries/transcripts. Session rows use the
same SQLite session tables as other rows:
- `openclaw sessions cleanup --enforce` maintains old isolated cron run sessions through `session.maintenance`.
- Legacy cron session imports happen through `openclaw doctor --fix`.
- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune SQLite cron run history (defaults: `2_000_000` approximate serialized bytes and `2000` rows per job).
When cron force-creates a new isolated run session, it sanitizes the previous
@@ -181,7 +175,7 @@ Key fields (not exhaustive):
freshness uses this so heartbeat, cron, and exec events do not keep sessions
alive. Legacy rows without this field fall back to the recovered session start
time for idle freshness.
- `updatedAt`: last store-row mutation timestamp, used for listing, pruning, and
- `updatedAt`: last store-row mutation timestamp, used for listing and
bookkeeping. It is not the authority for daily/idle reset freshness.
- `sessionFile`: optional explicit transcript path override
- `chatType`: `direct | group | room` (helps UIs and send policy)
@@ -448,11 +442,11 @@ flush logic lives on the Gateway side today.
## Troubleshooting checklist
- Session key wrong? Start with [/concepts/session](/concepts/session) and confirm the `sessionKey` in `/status`.
- Store vs transcript mismatch? Confirm the Gateway host and the store path from `openclaw status`.
- Session metadata vs transcript mismatch? Confirm the Gateway host and agent database from `openclaw status`.
- Compaction spam? Check:
- model context window (too small)
- compaction settings (`reserveTokens` too high for the model window can cause earlier compaction)
- tool-result bloat: enable/tune session pruning
- tool-result bloat: review compaction thresholds and tool-result persistence
- Silent turns leaking? Confirm the reply starts with `NO_REPLY` (case-insensitive exact token) and you're on a build that includes the streaming suppression fix.
## Related

View File

@@ -545,9 +545,9 @@ Two ways to start an ACP session:
</ParamField>
<ParamField path="streamTo" type='"parent"'>
`"parent"` streams initial ACP run progress summaries back to the
requester session as system events. Accepted responses include
`streamLogPath` pointing to a session-scoped JSONL log
(`<sessionId>.acp-stream.jsonl`) you can tail for full relay history.
requester session as system events. Full relay diagnostics are recorded
as structured rows in the child agent database, not as adjacent JSONL
sidecars.
</ParamField>
<ParamField path="runTimeoutSeconds" type="number">
Aborts the ACP child turn after N seconds. `0` keeps the turn on the
@@ -782,8 +782,7 @@ backend-level session identifiers. Unsupported-control errors surface
clearly when a backend lacks a capability. `/acp sessions` reads the
store for the current bound or requester session; target tokens
(`session-key`, `session-id`, or `session-label`) resolve through
gateway session discovery, including custom per-agent `session.store`
roots.
gateway session discovery backed by per-agent SQLite metadata.
### Runtime options mapping

View File

@@ -15,10 +15,9 @@ import {
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptsDirForAgent,
resolveStorePath,
resolveLegacySessionStorePath,
} from "../config/sessions/paths.js";
import { loadSessionStore } from "../config/sessions/store-load.js";
import { updateSessionStore } from "../config/sessions/store.js";
import { listSessionEntries, upsertSessionEntry } from "../config/sessions/store.js";
import {
hasSqliteSessionTranscriptEvents,
loadSqliteSessionTranscriptEvents,
@@ -649,7 +648,7 @@ export async function noteStateIntegrity(
const oauthDir = resolveOAuthDir(env, stateDir);
const agentId = resolveDefaultAgentId(cfg);
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, homedir);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const storePath = resolveLegacySessionStorePath(undefined, { agentId });
const storeDir = path.dirname(storePath);
const absoluteStorePath = path.resolve(storePath);
const displayStateDir = shortenHomePath(stateDir);
@@ -876,7 +875,9 @@ export async function noteStateIntegrity(
);
}
const store = loadSessionStore(storePath);
const store = Object.fromEntries(
listSessionEntries({ agentId, env }).map(({ sessionKey, entry }) => [sessionKey, entry]),
);
const sessionPathOpts = resolveSessionFilePathOptions({ agentId, storePath });
const entries = Object.entries(store).filter(([, entry]) => entry && typeof entry === "object");
if (entries.length > 0) {
@@ -901,9 +902,8 @@ export async function noteStateIntegrity(
warnings.push(
[
`- ${missing.length}/${recentTranscriptCandidates.length} recent sessions are missing transcripts.`,
` Import legacy session indexes: ${formatCliCommand("openclaw doctor --fix")}`,
` Preview cleanup impact: ${formatCliCommand("openclaw sessions cleanup --dry-run")}`,
` Prune missing entries: ${formatCliCommand("openclaw sessions cleanup --enforce --fix-missing")}`,
` Run migration/import repair: ${formatCliCommand("openclaw doctor --fix")}`,
" If the transcript sources are gone, reset or delete the affected sessions explicitly.",
].join("\n"),
);
}
@@ -931,15 +931,17 @@ export async function noteStateIntegrity(
if (repairWedged) {
let repaired = 0;
const repairedAt = Date.now();
await updateSessionStore(absoluteStorePath, (currentStore) => {
for (const [key] of wedgedSubagentSessions) {
const current = currentStore[key];
if (current && clearWedgedSubagentRecoveryAbort(current, repairedAt)) {
for (const [key] of wedgedSubagentSessions) {
const current = store[key];
if (current) {
const next = structuredClone(current);
if (clearWedgedSubagentRecoveryAbort(next, repairedAt)) {
repaired += 1;
currentStore[key] = current;
upsertSessionEntry({ agentId, env, sessionKey: key, entry: next });
store[key] = next;
}
}
});
}
if (repaired > 0) {
changes.push(
`- Cleared aborted restart-recovery flags for ${countLabel(
@@ -1036,7 +1038,7 @@ export async function noteStateIntegrity(
warnings.push(
[
`- Found ${orphanCount} in ${displaySessionsDir}.`,
" These .jsonl files are no longer referenced by sessions.json, so they are not part of any active session history.",
" These legacy .jsonl files are no longer referenced by SQLite session rows, so they are not part of any active session history.",
" Doctor can delete them after the session transcript migration/import has run.",
` Examples: ${orphanPreview}`,
].join("\n"),

View File

@@ -27,7 +27,7 @@ function expectMigrationChangesToIncludeFragments(changes: string[], fragments:
}
describe("legacy session maintenance migrate", () => {
it("removes deprecated session.maintenance.rotateBytes", () => {
it("removes ignored session.maintenance", () => {
const res = migrateLegacyConfigForTest({
session: {
maintenance: {
@@ -39,61 +39,72 @@ describe("legacy session maintenance migrate", () => {
},
});
expect(res.config?.session?.maintenance).toEqual({
mode: "enforce",
pruneAfter: "30d",
maxEntries: 500,
});
expect(res.changes).toContain("Removed deprecated session.maintenance.rotateBytes.");
});
it("removes deprecated session.maintenance disk budget settings", () => {
const res = migrateLegacyConfigForTest({
session: {
maintenance: {
mode: "enforce",
pruneAfter: "30d",
maxEntries: 500,
maxDiskBytes: "500mb",
highWaterBytes: "400mb",
},
},
});
expect(res.config?.session?.maintenance).toEqual({
mode: "enforce",
pruneAfter: "30d",
maxEntries: 500,
});
expect(res.config?.session).toEqual({});
expect(res.changes).toContain(
"Removed deprecated session.maintenance.maxDiskBytes/highWaterBytes; session transcripts are stored in SQLite.",
"Removed ignored session.maintenance; SQLite sessions do not prune rows.",
);
});
it("removes legacy session.maintenance.resetArchiveRetention", () => {
it("removes ignored session.writeLock", () => {
const res = migrateLegacyConfigForTest({
session: {
maintenance: {
mode: "enforce",
pruneAfter: "30d",
maxEntries: 500,
resetArchiveRetention: "14d",
writeLock: {
acquireTimeoutMs: 120_000,
},
},
});
expect(res.config?.session?.maintenance).toEqual({
mode: "enforce",
pruneAfter: "30d",
maxEntries: 500,
expect(res.config?.session).toEqual({});
expect(res.changes).toContain(
"Removed ignored session.writeLock; SQLite serializes session writes.",
);
});
it("keeps unrelated session settings while removing ignored maintenance and writeLock", () => {
const res = migrateLegacyConfigForTest({
session: {
store: "sessions.json",
idleMinutes: 120,
writeLock: {
acquireTimeoutMs: 120_000,
},
maintenance: {
mode: "enforce",
pruneAfter: "30d",
maxEntries: 500,
},
},
});
expect(res.config?.session).toEqual({
idleMinutes: 120,
});
expect(res.changes).toContain(
"Removed session.maintenance.resetArchiveRetention; reset transcript archives are no longer used.",
"Removed ignored session.store; sessions live in per-agent SQLite databases.",
);
expect(res.changes).toContain(
"Removed ignored session.maintenance; SQLite sessions do not prune rows.",
);
expect(res.changes).toContain(
"Removed ignored session.writeLock; SQLite serializes session writes.",
);
});
});
describe("legacy session parent fork migrate", () => {
it("removes ignored session.store", () => {
const res = migrateLegacyConfigForTest({
session: {
store: "sessions.json",
},
});
expect(res.config?.session).toEqual({});
expect(res.changes).toContain(
"Removed ignored session.store; sessions live in per-agent SQLite databases.",
);
});
it("removes legacy session.parentForkMaxTokens", () => {
const res = migrateLegacyConfigForTest({
session: {
@@ -102,9 +113,10 @@ describe("legacy session parent fork migrate", () => {
},
});
expect(res.config?.session).toEqual({
store: "sessions.json",
});
expect(res.config?.session).toEqual({});
expect(res.changes).toContain(
"Removed ignored session.store; sessions live in per-agent SQLite databases.",
);
expect(res.changes).toContain(
"Removed session.parentForkMaxTokens; parent fork sizing is automatic.",
);

View File

@@ -5,25 +5,19 @@ import {
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"));
function hasLegacySessionMaintenance(value: unknown): boolean {
const session = getRecord(value);
return Boolean(session && Object.prototype.hasOwnProperty.call(session, "maintenance"));
}
function hasLegacyDiskBudget(value: unknown): boolean {
const maintenance = getRecord(value);
return Boolean(
maintenance &&
(Object.prototype.hasOwnProperty.call(maintenance, "maxDiskBytes") ||
Object.prototype.hasOwnProperty.call(maintenance, "highWaterBytes")),
);
function hasLegacySessionWriteLock(value: unknown): boolean {
const session = getRecord(value);
return Boolean(session && Object.prototype.hasOwnProperty.call(session, "writeLock"));
}
function hasLegacyResetArchiveRetention(value: unknown): boolean {
const maintenance = getRecord(value);
return Boolean(
maintenance && Object.prototype.hasOwnProperty.call(maintenance, "resetArchiveRetention"),
);
function hasLegacySessionStore(value: unknown): boolean {
const session = getRecord(value);
return Boolean(session && Object.prototype.hasOwnProperty.call(session, "store"));
}
function hasLegacyParentForkMaxTokens(value: unknown): boolean {
@@ -31,27 +25,6 @@ function hasLegacyParentForkMaxTokens(value: unknown): boolean {
return Boolean(session && Object.prototype.hasOwnProperty.call(session, "parentForkMaxTokens"));
}
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,
};
const LEGACY_SESSION_MAINTENANCE_DISK_BUDGET_RULE: LegacyConfigRule = {
path: ["session", "maintenance"],
message:
'session.maintenance.maxDiskBytes/highWaterBytes are deprecated and ignored; run "openclaw doctor --fix" to remove them.',
match: hasLegacyDiskBudget,
};
const LEGACY_SESSION_MAINTENANCE_RESET_ARCHIVE_RETENTION_RULE: LegacyConfigRule = {
path: ["session", "maintenance"],
message:
'session.maintenance.resetArchiveRetention was removed with reset transcript archives; run "openclaw doctor --fix" to remove it.',
match: hasLegacyResetArchiveRetention,
};
const LEGACY_SESSION_PARENT_FORK_MAX_TOKENS_RULE: LegacyConfigRule = {
path: ["session"],
message:
@@ -59,61 +32,65 @@ const LEGACY_SESSION_PARENT_FORK_MAX_TOKENS_RULE: LegacyConfigRule = {
match: hasLegacyParentForkMaxTokens,
};
const LEGACY_SESSION_MAINTENANCE_RULE: LegacyConfigRule = {
path: ["session"],
message:
'session.maintenance is ignored with SQLite-backed sessions; run "openclaw doctor --fix" to remove it.',
match: hasLegacySessionMaintenance,
};
const LEGACY_SESSION_WRITE_LOCK_RULE: LegacyConfigRule = {
path: ["session"],
message:
'session.writeLock is ignored because SQLite serializes session writes; run "openclaw doctor --fix" to remove it.',
match: hasLegacySessionWriteLock,
};
const LEGACY_SESSION_STORE_RULE: LegacyConfigRule = {
path: ["session"],
message:
'session.store is ignored because sessions live in per-agent SQLite databases; run "openclaw doctor --fix" to remove it.',
match: hasLegacySessionStore,
};
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],
id: "session.store",
describe: "Remove ignored legacy session.store settings",
legacyRules: [LEGACY_SESSION_STORE_RULE],
apply: (raw, changes) => {
const maintenance = getRecord(getRecord(raw.session)?.maintenance);
if (!maintenance || !Object.prototype.hasOwnProperty.call(maintenance, "rotateBytes")) {
const session = getRecord(raw.session);
if (!session || !Object.prototype.hasOwnProperty.call(session, "store")) {
return;
}
delete maintenance.rotateBytes;
changes.push("Removed deprecated session.maintenance.rotateBytes.");
delete session.store;
changes.push("Removed ignored session.store; sessions live in per-agent SQLite databases.");
},
}),
defineLegacyConfigMigration({
id: "session.maintenance.diskBudget",
describe: "Remove deprecated session.maintenance disk budget settings",
legacyRules: [LEGACY_SESSION_MAINTENANCE_DISK_BUDGET_RULE],
id: "session.maintenance",
describe: "Remove ignored session.maintenance settings",
legacyRules: [LEGACY_SESSION_MAINTENANCE_RULE],
apply: (raw, changes) => {
const maintenance = getRecord(getRecord(raw.session)?.maintenance);
if (!maintenance) {
const session = getRecord(raw.session);
if (!session || !Object.prototype.hasOwnProperty.call(session, "maintenance")) {
return;
}
let removed = false;
if (Object.prototype.hasOwnProperty.call(maintenance, "maxDiskBytes")) {
delete maintenance.maxDiskBytes;
removed = true;
}
if (Object.prototype.hasOwnProperty.call(maintenance, "highWaterBytes")) {
delete maintenance.highWaterBytes;
removed = true;
}
if (removed) {
changes.push(
"Removed deprecated session.maintenance.maxDiskBytes/highWaterBytes; session transcripts are stored in SQLite.",
);
}
delete session.maintenance;
changes.push("Removed ignored session.maintenance; SQLite sessions do not prune rows.");
},
}),
defineLegacyConfigMigration({
id: "session.maintenance.resetArchiveRetention",
describe: "Remove legacy session.maintenance.resetArchiveRetention",
legacyRules: [LEGACY_SESSION_MAINTENANCE_RESET_ARCHIVE_RETENTION_RULE],
id: "session.writeLock",
describe: "Remove ignored session.writeLock settings",
legacyRules: [LEGACY_SESSION_WRITE_LOCK_RULE],
apply: (raw, changes) => {
const maintenance = getRecord(getRecord(raw.session)?.maintenance);
if (
!maintenance ||
!Object.prototype.hasOwnProperty.call(maintenance, "resetArchiveRetention")
) {
const session = getRecord(raw.session);
if (!session || !Object.prototype.hasOwnProperty.call(session, "writeLock")) {
return;
}
delete maintenance.resetArchiveRetention;
changes.push(
"Removed session.maintenance.resetArchiveRetention; reset transcript archives are no longer used.",
);
delete session.writeLock;
changes.push("Removed ignored session.writeLock; SQLite serializes session writes.");
},
}),
defineLegacyConfigMigration({

View File

@@ -155,7 +155,6 @@ const TARGET_KEYS = [
"session.resetByType.group",
"session.resetByType.thread",
"session.resetByChannel",
"session.store",
"session.typingIntervalSeconds",
"session.typingMode",
"session.mainKey",
@@ -176,14 +175,6 @@ const TARGET_KEYS = [
"session.threadBindings.maxAgeHours",
"session.threadBindings.spawnSessions",
"session.threadBindings.defaultSpawnContext",
"session.maintenance",
"session.maintenance.mode",
"session.maintenance.pruneAfter",
"session.maintenance.pruneDays",
"session.maintenance.maxEntries",
"session.maintenance.rotateBytes",
"session.maintenance.maxDiskBytes",
"session.maintenance.highWaterBytes",
"approvals",
"approvals.exec",
"approvals.exec.enabled",
@@ -699,33 +690,6 @@ describe("config help copy quality", () => {
expect(/raw|unnormalized/i.test(rawKeyPrefix)).toBe(true);
});
it("documents session write-lock acquire timeout defaults", () => {
const acquireTimeout = FIELD_HELP["session.writeLock.acquireTimeoutMs"];
expect(acquireTimeout.includes("60000")).toBe(true);
expect(/transcript|lock/i.test(acquireTimeout)).toBe(true);
});
it("documents session maintenance duration examples and deprecations", () => {
const pruneAfter = FIELD_HELP["session.maintenance.pruneAfter"];
expect(pruneAfter.includes("30d")).toBe(true);
expect(pruneAfter.includes("12h")).toBe(true);
const rotate = FIELD_HELP["session.maintenance.rotateBytes"];
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);
expect(deprecated.includes("session.maintenance.pruneAfter")).toBe(true);
const maxDisk = FIELD_HELP["session.maintenance.maxDiskBytes"];
expect(/deprecated|ignored/i.test(maxDisk)).toBe(true);
expect(maxDisk.includes("doctor --fix")).toBe(true);
const highWater = FIELD_HELP["session.maintenance.highWaterBytes"];
expect(/deprecated|ignored/i.test(highWater)).toBe(true);
});
it("documents cron run-log retention controls", () => {
const runLog = FIELD_HELP["cron.runLog"];
expect(runLog.includes("SQLite")).toBe(true);

View File

@@ -1172,9 +1172,9 @@ export const FIELD_HELP: Record<string, string> = {
"memory.qmd.sessions.enabled":
"Indexes session transcripts into QMD so recall can include prior conversation content (experimental, default: false). Enable only when transcript memory is required and you accept larger index churn.",
"memory.qmd.sessions.exportDir":
"Overrides where sanitized session exports are written before QMD indexing. Use this when default state storage is constrained or when exports must land on a managed volume.",
"Overrides where the optional QMD markdown export cache is written before QMD indexing. Use this only for QMD adapter storage; it is not the canonical session store and sessions remain SQLite-backed.",
"memory.qmd.sessions.retentionDays":
"Defines how long exported session files are kept before automatic pruning, in days (default: unlimited). Set a finite value for storage hygiene or compliance retention policies.",
"Defines how long optional QMD markdown export-cache files are kept, in days (default: unlimited). Session retention itself is SQLite-backed and not controlled by this setting.",
"memory.qmd.update.interval":
"Sets how often QMD refreshes indexes from source content (duration string, default: 5m). Shorter intervals improve freshness but increase background CPU and I/O.",
"memory.qmd.update.debounceMs":
@@ -1380,9 +1380,9 @@ export const FIELD_HELP: Record<string, string> = {
"agents.defaults.compaction.model":
"Optional provider/model override used only for compaction summarization. Set this when you want compaction to run on a different model than the session default, and leave it unset to keep using the primary agent model.",
"agents.defaults.compaction.truncateAfterCompaction":
"When enabled, rotates the active session JSONL file after compaction so future turns load only the summary and unsummarized tail while the previous full transcript remains archived. Prevents unbounded active transcript growth in long-running sessions. Default: false.",
"When enabled, rotates the active SQLite transcript after compaction so future turns load only the summary and unsummarized tail while the previous full transcript remains available as a checkpoint snapshot. Prevents unbounded active transcript growth in long-running sessions. Default: false.",
"agents.defaults.compaction.maxActiveTranscriptBytes":
'Triggers normal local compaction when the active session transcript reaches this size (bytes or strings like "20mb"). Requires truncateAfterCompaction so successful compaction can rotate to a smaller successor transcript; set to 0 or leave unset to disable. This never splits raw transcript bytes.',
'Triggers normal local compaction when the active SQLite transcript reaches this size (bytes or strings like "20mb"). Requires truncateAfterCompaction so successful compaction can rotate to a smaller successor transcript; set to 0 or leave unset to disable. This never splits raw transcript bytes or events.',
"agents.defaults.compaction.notifyUser":
"When enabled, sends brief compaction notices to the user when compaction starts and when it completes (for example, '🧹 Compacting context...' and '🧹 Compaction complete'). Disabled by default to keep compaction silent and non-intrusive.",
"agents.defaults.compaction.memoryFlush":
@@ -1477,8 +1477,6 @@ export const FIELD_HELP: Record<string, string> = {
"Defines reset policy for thread-scoped sessions, including focused channel thread workflows. Use this when thread sessions should expire faster or slower than other chat types.",
"session.resetByChannel":
"Provides channel-specific reset overrides keyed by provider/channel id for fine-grained behavior control. Use this only when one channel needs exceptional reset behavior beyond type-level policies.",
"session.store":
"Sets the session storage file path used to persist session records across restarts. Use an explicit path only when you need custom disk layout, backup routing, or mounted-volume storage.",
"session.typingIntervalSeconds":
"Controls interval for repeated typing indicators while replies are being prepared in typing-capable channels. Increase to reduce chatty updates or decrease for more active typing feedback.",
"session.typingMode":
@@ -1503,10 +1501,6 @@ export const FIELD_HELP: Record<string, string> = {
"Matches a normalized session-key prefix after internal key normalization steps in policy consumers. Use this for general prefix controls, and prefer rawKeyPrefix when exact full-key matching is required.",
"session.sendPolicy.rules[].match.rawKeyPrefix":
"Matches the raw, unnormalized session-key prefix for exact full-key policy targeting. Use this when normalized keyPrefix is too broad and you need agent-prefixed or transport-specific precision.",
"session.writeLock":
"Groups session transcript write-lock acquisition controls. Tune only when legitimate transcript prep, cleanup, compaction, or mirror work contends longer than the default wait.",
"session.writeLock.acquireTimeoutMs":
"Milliseconds to wait while acquiring a session transcript write lock before reporting the session as busy. Default: 60000; raise for slow disks or long prep/cleanup, lower only when quick failure is preferred.",
"session.agentToAgent":
"Groups controls for inter-agent session exchanges, including loop prevention limits on reply chaining. Keep defaults unless you run advanced agent-to-agent automation with strict turn caps.",
"session.agentToAgent.maxPingPongTurns":
@@ -1523,27 +1517,11 @@ export const FIELD_HELP: Record<string, string> = {
"Global default gate for creating thread-bound work sessions from sessions_spawn and ACP thread spawns. Default: true when thread bindings are enabled.",
"session.threadBindings.defaultSpawnContext":
'Default native subagent context for thread-bound spawns. Use "fork" to start from the requester transcript or "isolated" for a clean child. Default: "fork".',
"session.maintenance":
"Explicit SQLite session-row maintenance controls for age and entry-count retention. 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":
"Removes entries older than this duration (for example `30d` or `12h`) during maintenance passes. Use this as the primary age-retention control and align it with data retention policy.",
"session.maintenance.pruneDays":
"Deprecated age-retention field kept for compatibility with legacy configs using day counts. Use session.maintenance.pruneAfter instead so duration syntax and behavior are consistent.",
"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":
'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.maxDiskBytes":
"Deprecated and ignored. Session transcripts now live in SQLite; use openclaw doctor --fix to remove legacy disk-budget settings.",
"session.maintenance.highWaterBytes":
"Deprecated and ignored with session.maintenance.maxDiskBytes. Use openclaw doctor --fix to remove it from legacy configs.",
cron: "Global scheduler settings for stored cron jobs, run concurrency, delivery fallback, and run-session retention. Keep defaults unless you are scaling job volume or integrating external webhook receivers.",
"cron.enabled":
"Enables cron job execution for stored schedules managed by the gateway. Keep enabled for normal reminder/automation flows, and disable only to pause all cron execution without deleting jobs.",
"cron.store":
"Path to the cron job store file used to persist scheduled jobs across restarts. Set an explicit path only when you need custom storage layout, backups, or mounted volumes.",
"Legacy cron store path used as the import namespace for old jobs.json files. Scheduled jobs now persist in the shared SQLite state database; set this only when importing or identifying a custom legacy store.",
"cron.maxConcurrentRuns":
"Limits how many cron jobs can execute at the same time when multiple schedules fire together, including isolated agent-turn LLM execution on the dedicated cron-nested lane. Use lower values to protect CPU/memory under heavy automation load, or raise carefully for higher throughput.",
"cron.retry":

View File

@@ -717,7 +717,6 @@ export const FIELD_LABELS: Record<string, string> = {
"session.resetByType.group": "Session Reset (Group)",
"session.resetByType.thread": "Session Reset (Thread)",
"session.resetByChannel": "Session Reset by Channel",
"session.store": "Session Store Path",
"session.typingIntervalSeconds": "Session Typing Interval (seconds)",
"session.typingMode": "Session Typing Mode",
"session.mainKey": "Session Main Key",
@@ -730,8 +729,6 @@ export const FIELD_LABELS: Record<string, string> = {
"session.sendPolicy.rules[].match.chatType": "Session Send Rule Chat Type",
"session.sendPolicy.rules[].match.keyPrefix": "Session Send Rule Key Prefix",
"session.sendPolicy.rules[].match.rawKeyPrefix": "Session Send Rule Raw Key Prefix",
"session.writeLock": "Session Write Lock",
"session.writeLock.acquireTimeoutMs": "Session Write Lock Acquire Timeout",
"session.agentToAgent": "Session Agent-to-Agent",
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
"session.threadBindings": "Session Thread Bindings",
@@ -740,14 +737,6 @@ export const FIELD_LABELS: Record<string, string> = {
"session.threadBindings.maxAgeHours": "Thread Binding Max Age (hours)",
"session.threadBindings.spawnSessions": "Thread-Bound Session Spawns",
"session.threadBindings.defaultSpawnContext": "Thread Spawn Context",
"session.maintenance": "Session Maintenance",
"session.maintenance.mode": "Session Maintenance Mode",
"session.maintenance.pruneAfter": "Session Prune After",
"session.maintenance.pruneDays": "Session Prune Days (Deprecated)",
"session.maintenance.maxEntries": "Session Max Entries",
"session.maintenance.rotateBytes": "Deprecated Session Rotate Size",
"session.maintenance.maxDiskBytes": "Deprecated Session Max Disk Budget",
"session.maintenance.highWaterBytes": "Deprecated Session Disk High-water Target",
cron: "Cron",
"cron.enabled": "Cron Enabled",
"cron.store": "Cron Store Path",

View File

@@ -197,45 +197,16 @@ export type SessionConfig = {
resetByType?: SessionResetByTypeConfig;
/** Channel-specific reset overrides (e.g. { discord: { mode: "idle", idleMinutes: 10080 } }). */
resetByChannel?: Record<string, SessionResetConfig>;
store?: string;
typingIntervalSeconds?: number;
typingMode?: TypingMode;
mainKey?: string;
sendPolicy?: SessionSendPolicyConfig;
/** Session transcript write-lock acquisition policy. */
writeLock?: SessionWriteLockConfig;
agentToAgent?: {
/** Max ping-pong turns between requester/target (05). Default: 5. */
maxPingPongTurns?: number;
};
/** Shared defaults for thread-bound session routing across channels/providers. */
threadBindings?: SessionThreadBindingsConfig;
/** Explicit SQLite session-row maintenance (age and entry-count retention). */
maintenance?: SessionMaintenanceConfig;
};
export type SessionWriteLockConfig = {
/** How long to wait while acquiring a session transcript write lock. Default: 60000. */
acquireTimeoutMs?: number;
};
export type SessionMaintenanceMode = "enforce" | "warn";
export type SessionMaintenanceConfig = {
/** Whether to enforce maintenance or warn only. Default: "warn". */
mode?: SessionMaintenanceMode;
/** Remove session entries older than this duration (e.g. "30d", "12h"). Default: "30d". */
pruneAfter?: string | number;
/** @deprecated Use pruneAfter instead. */
pruneDays?: number;
/** Maximum number of session entries to keep. Default: 500. */
maxEntries?: number;
/** @deprecated Ignored. Run `openclaw doctor --fix` to remove. */
rotateBytes?: number | string;
/** @deprecated Ignored. Session transcripts are stored in SQLite. */
maxDiskBytes?: number | string;
/** @deprecated Ignored with maxDiskBytes. */
highWaterBytes?: number | string;
};
export type LoggingConfig = {

View File

@@ -1,6 +1,4 @@
import { z } from "zod";
import { parseDurationMs } from "../cli/parse-duration.js";
import { normalizeStringifiedOptionalString } from "../shared/string-coerce.js";
import { ElevatedAllowFromSchema } from "./zod-schema.agent-runtime.js";
import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js";
import {
@@ -50,17 +48,10 @@ export const SessionSchema = z
.strict()
.optional(),
resetByChannel: z.record(z.string(), SessionResetConfigSchema).optional(),
store: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
typingMode: TypingModeSchema.optional(),
mainKey: z.string().optional(),
sendPolicy: SessionSendPolicySchema.optional(),
writeLock: z
.object({
acquireTimeoutMs: z.number().int().positive().optional(),
})
.strict()
.optional(),
agentToAgent: z
.object({
maxPingPongTurns: z.number().int().min(0).max(5).optional(),
@@ -77,34 +68,6 @@ export const SessionSchema = z
})
.strict()
.optional(),
maintenance: z
.object({
mode: z.enum(["enforce", "warn"]).optional(),
pruneAfter: z.union([z.string(), z.number()]).optional(),
/** @deprecated Use pruneAfter instead. */
pruneDays: z.number().int().positive().optional(),
maxEntries: z.number().int().positive().optional(),
rotateBytes: z.union([z.string(), z.number()]).optional(),
maxDiskBytes: z.union([z.string(), z.number()]).optional(),
highWaterBytes: z.union([z.string(), z.number()]).optional(),
})
.strict()
.superRefine((val, ctx) => {
if (val.pruneAfter !== undefined) {
try {
parseDurationMs(normalizeStringifiedOptionalString(val.pruneAfter) ?? "", {
defaultUnit: "d",
});
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["pruneAfter"],
message: "invalid duration (use ms, s, m, h, d)",
});
}
}
})
.optional(),
})
.strict()
.optional();