feat: disable automatic session resets by default (#111140)

* feat(config): disable automatic session resets by default

* fix(sessions): honor pending reset tombstones

* test(sessions): align reset coverage with disabled default

* fix(sessions): preserve explicit reset override fallback

* fix(sessions): inherit active mode in partial type resets
This commit is contained in:
Peter Steinberger
2026-07-18 21:50:48 -07:00
committed by GitHub
parent d5cb708623
commit e23dde3de5
27 changed files with 401 additions and 143 deletions
+5 -4
View File
@@ -65,10 +65,11 @@ continuity comes from layers around it:
The main session rolls forward through resets and compaction rather than
growing forever:
- By default the session resets daily at 04:00 local time (configurable, or
idle-based; see [Session management](/concepts/session)). On `/new` and
`/reset`, the tail of the ending conversation is saved to daily memory
notes, and the next session re-primes recent notes.
- By default there is no automatic reset; compaction keeps the active context
bounded while preserving the rolling session. Daily and idle resets are
opt-in (see [Session management](/concepts/session)). On `/new` and `/reset`,
the tail of the ending conversation is saved to daily memory notes, and the
next session re-primes recent notes.
- When the conversation approaches the context window, compaction summarizes
and continues in place — the transcript history stays in the session store.
- The per-agent session store keeps archived transcripts until a disk budget
+9 -7
View File
@@ -91,13 +91,15 @@ and runtime details.
## Session lifecycle
Sessions are reused until they expire under `session.reset`:
Sessions are reused until you reset them manually or opt into an automatic reset policy:
- **Daily reset** (default `mode: "daily"`) - new session at a configured local
- **No automatic reset** (default `mode: "none"`) - sessions keep the same
`sessionId`; compaction manages the active context as the conversation grows.
- **Daily reset** (`mode: "daily"`) - opt into a new session at a configured local
hour (`session.reset.atHour`, default `4`, 0-23) on the gateway host. Daily
freshness is based on when the current `sessionId` started, not on later
metadata writes.
- **Idle reset** (`mode: "idle"`) - new session after `session.reset.idleMinutes`
- **Idle reset** (`mode: "idle"`) - opt into a new session after `session.reset.idleMinutes`
of inactivity. Idle freshness is based on the last real user/channel
interaction, so heartbeat, cron, and exec system events do not keep the
session alive.
@@ -111,11 +113,11 @@ rolls the session, queued system-event notices for the old session are
discarded so stale background updates are not prepended to the first prompt in
the new session.
Sessions with an active provider-owned CLI session are not cut by the implicit
daily default. Use `/reset` or configure `session.reset` explicitly when those
sessions should expire on a timer.
Sessions with an active provider-owned CLI session follow the same no-automatic-reset
default. Use `/reset` or configure `session.reset` explicitly when those sessions
should expire on a timer.
Override the default per chat type or per channel:
Opt into automatic resets globally, then override them per chat type or channel:
```json5
{
+1 -1
View File
@@ -193,7 +193,7 @@ Set `agents.defaults.cliBackends.claude-cli.command` only when the `claude` bina
- `none`: never send a session id.
- `claude-cli` defaults to `liveSession: "claude-stdio"`, `output: "jsonl"`, and `input: "stdin"`, so follow-up turns reuse the live Claude process while it is active, including for custom configs that omit transport fields. If the gateway restarts or the idle process exits, OpenClaw resumes from the stored Claude session id. Stored session ids are verified against a readable project transcript before resume; a missing transcript clears the binding (logged as `reason=transcript-missing`) instead of silently starting a fresh session under `--resume`.
- Claude live sessions keep bounded JSONL output guards: 8 MiB and 20,000 raw JSONL lines per turn by default. Raise them per backend with `agents.defaults.cliBackends.claude-cli.reliability.outputLimits.maxTurnRawChars` and `maxTurnLines`; OpenClaw clamps those settings to 64 MiB and 100,000 lines.
- Stored CLI sessions are provider-owned continuity. The implicit daily session reset does not cut them; `/reset` and explicit `session.reset` policies still do.
- Stored CLI sessions are provider-owned continuity. Automatic reset is disabled by default; `/reset` and explicit daily or idle `session.reset` policies still cut them.
- Fresh CLI sessions normally reseed only from OpenClaw's compaction summary plus the post-compaction tail. To recover short sessions invalidated before compaction, a backend can opt in with `reseedFromRawTranscriptWhenUncompacted: true`. Raw transcript reseed stays bounded and limited to safe invalidations, such as a missing CLI transcript, an orphaned tool-use tail, message-policy/system-prompt/cwd/MCP changes, or a session-expired retry; auth profile or credential-epoch changes never reseed raw transcript history.
Serialization: `serialize: true` keeps same-lane runs ordered (most CLIs serialize on one provider lane). OpenClaw also drops stored CLI session reuse when the selected auth identity changes, including a changed auth profile id, static API key, static token, or OAuth account identity when the CLI exposes one; OAuth access/refresh token rotation alone does not cut the session. If a CLI has no stable OAuth account id, OpenClaw lets that CLI enforce its own resume permissions.
+1 -1
View File
@@ -1319,7 +1319,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- `per-channel-peer`: isolate per channel + sender (recommended for multi-user inboxes).
- `per-account-channel-peer`: isolate per account + channel + sender (recommended for multi-account).
- **`identityLinks`**: map canonical ids to provider-prefixed peers for cross-channel session sharing. Dock commands such as `/dock_discord` use the same map to switch the active session's reply route to another linked channel peer; see [Channel docking](/concepts/channel-docking).
- **`reset`**: primary reset policy. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins. Daily reset freshness uses the session row's `sessionStartedAt`; idle reset freshness uses `lastInteractionAt`. Background/system-event writes such as heartbeat, cron wakeups, exec notifications, and gateway bookkeeping can update `updatedAt`, but they do not keep daily/idle sessions fresh.
- **`reset`**: primary reset policy. `none` disables automatic reset and is the default; compaction bounds active context instead. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins. `/new` and `/reset` remain available in every mode. Daily reset freshness uses the session row's `sessionStartedAt`; idle reset freshness uses `lastInteractionAt`. Background/system-event writes such as heartbeat, cron wakeups, exec notifications, and gateway bookkeeping can update `updatedAt`, but they do not keep daily/idle sessions fresh.
- **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`.
- **`resetByChannel`**: per-channel reset overrides keyed by provider/channel id. When the session's channel has a matching entry, it wins outright over `resetByType`/`reset` for that session. Use only when one channel needs reset behavior different from the type-level policy.
- **`mainKey`**: legacy field. Runtime always uses `"main"` for the main direct-chat bucket.
+2 -2
View File
@@ -960,7 +960,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures -
</Accordion>
<Accordion title="Do sessions reset automatically if I never send /new?">
Yes. The default reset policy is **daily**: a session rolls over at a configured local hour on the gateway host (`session.reset.atHour`, default `4`, 0-23), based on when the current session started. Switch to idle-based reset instead with `mode: "idle"` and `session.reset.idleMinutes`, which expires a session after a period of inactivity (based on the last real interaction, not heartbeat/cron/exec system events).
No, not by default. Sessions keep the same `sessionId`, and compaction bounds the active model context as conversations grow. `/new` and `/reset` remain available, or you can opt into automatic resets with `mode: "daily"` or `mode: "idle"`. Daily mode rolls over at `session.reset.atHour` (default `4`, 0-23) on the gateway host; idle mode uses `session.reset.idleMinutes` since the last real interaction, not heartbeat/cron/exec system events.
```json5
{
@@ -977,7 +977,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures -
}
```
`resetByType` supports `direct` (legacy alias `dm`), `group`, and `thread`. Legacy top-level `session.idleMinutes` still works as a compatibility alias for an idle-mode default when no `session.reset`/`resetByType` block is set. Sessions with an active provider-owned CLI session are not cut by the implicit daily default. See [Session management](/concepts/session) for the full lifecycle.
`resetByType` supports `direct` (legacy alias `dm`), `group`, and `thread`. Legacy top-level `session.idleMinutes` still works as a compatibility alias for an idle-mode default when no `session.reset`/`resetByType` block is set. See [Session management](/concepts/session) for the full lifecycle.
</Accordion>
+1 -1
View File
@@ -192,7 +192,7 @@ two-party event loops that do not go through the shared inbound reply runner.
Use `runWithWorkAdmission(...)` when a plugin starts work on a persisted session. The callback rejects archived or concurrently replaced sessions, keeps archive/reset/delete mutations coordinated through completion, and receives an `AbortSignal` that must be forwarded to the agent run. A harness may explicitly name trusted execution delegates through its experimental `delegatedExecutionPluginIds` registration field. Delegates can admit and run only an exact existing model-locked session; all session mutations remain restricted to the harness owner. See [Agent harness plugins](/plugins/sdk-agent-harness#delegated-execution).
Maintenance and repair plugins may use `deleteSessionEntry(...)` for one scoped session entry, `cleanupSessionLifecycleArtifacts(...)` for lifecycle-owned scratch sessions, and `resolveSessionStoreBackupPaths(...)` before mutating a store. These helpers are narrow repair/lifecycle surfaces, not a general store deletion API.
Maintenance and repair plugins may use `deleteSessionEntry(...)` for one scoped session entry, `cleanupSessionLifecycleArtifacts(...)` for lifecycle-owned scratch sessions, and `resolveSessionStoreBackupPaths(...)` before mutating a store. Pass `expectedSessionId` and `expectedUpdatedAt` when deletion must not race a concurrent session update; use `expectedSessionId: null` when the earlier snapshot had no session id. These helpers are narrow repair/lifecycle surfaces, not a general store deletion API.
`resolveStorePath(...)` and `updateSessionStoreEntry(...)` round out the session helpers: `resolveStorePath` resolves the session store path for a given scope, and `updateSessionStoreEntry({ storePath, sessionKey, update })` patches one entry directly by store path when the caller already knows it.
@@ -138,8 +138,9 @@ A `sessionKey` identifies which conversation bucket you are in (routing + isolat
Each `sessionKey` points at a current `sessionId` (the SQLite transcript identity that continues the conversation). Decision logic lives in `initSessionState()` in `src/auto-reply/reply/session.ts`.
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
- **Idle expiry** (`session.reset.idleMinutes`, or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. If daily and idle are both configured, whichever expires first wins.
- **No automatic reset** is the default. The current `sessionId` continues while compaction keeps the active model context bounded.
- **Daily reset** (`session.reset.mode: "daily"`) creates a new `sessionId` on the next message after the configured local-hour boundary (`session.reset.atHour`, default `4`).
- **Idle expiry** (`session.reset.mode: "idle"` with `session.reset.idleMinutes`, or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. If daily and idle are both configured, whichever expires first wins.
- **Control UI reconnect resume** preserves the currently visible session for one reconnect send when the Gateway receives the matching `sessionId` from an operator UI client. This is a one-shot signal; ordinary stale sends still create a new `sessionId`.
- **System events** (heartbeat, cron wakeups, exec notifications, gateway bookkeeping) may mutate the session row but never extend daily/idle reset freshness. Reset rollover discards queued system-event notices for the previous session before the fresh prompt is built.
- **Parent fork policy** uses OpenClaw's active branch when creating a thread or subagent fork. If that branch is too large (over a fixed internal cap, currently 100K tokens), OpenClaw starts the child with isolated context instead of failing or inheriting unusable history. Sizing is automatic and not configurable; legacy `session.parentForkMaxTokens` config is removed by `openclaw doctor --fix`.
@@ -1,12 +1,11 @@
// Discord tests cover thread session close plugin behavior.
import { expectDefined } from "@openclaw/normalization-core";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => {
const deleteSessionEntry = vi.fn();
const listSessionEntries = vi.fn();
const patchSessionEntry = vi.fn();
const resolveStorePath = vi.fn(() => "/tmp/openclaw-sessions.json");
return { listSessionEntries, patchSessionEntry, resolveStorePath };
return { deleteSessionEntry, listSessionEntries, resolveStorePath };
});
vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
@@ -15,8 +14,8 @@ vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
);
return {
...actual,
deleteSessionEntry: hoisted.deleteSessionEntry,
listSessionEntries: hoisted.listSessionEntries,
patchSessionEntry: hoisted.patchSessionEntry,
resolveStorePath: hoisted.resolveStorePath,
};
});
@@ -27,24 +26,24 @@ function setupStore(store: Record<string, { sessionId?: string; updatedAt: numbe
hoisted.listSessionEntries.mockImplementation(() =>
Object.entries(store).map(([sessionKey, entry]) => ({ sessionKey, entry })),
);
hoisted.patchSessionEntry.mockImplementation(
hoisted.deleteSessionEntry.mockImplementation(
async (params: {
expectedSessionId?: string | null;
expectedUpdatedAt?: number;
sessionKey: string;
update: (entry: {
sessionId?: string;
updatedAt: number;
}) => { sessionId?: string; updatedAt: number } | null;
}) => {
const entry = store[params.sessionKey];
if (!entry) {
return null;
if (
!entry ||
(params.expectedSessionId === null
? entry.sessionId !== undefined
: entry.sessionId !== params.expectedSessionId) ||
entry.updatedAt !== params.expectedUpdatedAt
) {
return false;
}
const next = params.update({ ...entry });
if (!next) {
return entry;
}
store[params.sessionKey] = next;
return next;
delete store[params.sessionKey];
return true;
},
);
}
@@ -61,13 +60,13 @@ describe("closeDiscordThreadSessions", () => {
});
beforeEach(() => {
hoisted.deleteSessionEntry.mockReset();
hoisted.listSessionEntries.mockReset();
hoisted.patchSessionEntry.mockReset();
hoisted.resolveStorePath.mockClear();
hoisted.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions.json");
});
it("resets updatedAt to 0 for sessions whose key contains the threadId", async () => {
it("deletes sessions whose key contains the threadId", async () => {
const store = {
[MATCHED_KEY]: { updatedAt: 1_700_000_000_000 },
[UNMATCHED_KEY]: { updatedAt: 1_700_000_000_001 },
@@ -81,7 +80,7 @@ describe("closeDiscordThreadSessions", () => {
});
expect(count).toBe(1);
expect(store[MATCHED_KEY].updatedAt).toBe(0);
expect(store[MATCHED_KEY]).toBeUndefined();
expect(store[UNMATCHED_KEY].updatedAt).toBe(1_700_000_000_001);
});
@@ -101,7 +100,7 @@ describe("closeDiscordThreadSessions", () => {
expect(store[UNMATCHED_KEY].updatedAt).toBe(1_700_000_000_001);
});
it("resets all matching sessions when multiple keys contain the threadId", async () => {
it("deletes all matching sessions when multiple keys contain the threadId", async () => {
const keyA = `agent:main:discord:channel:${THREAD_ID}`;
const keyB = `agent:work:discord:channel:${THREAD_ID}`;
const keyC = `agent:main:discord:channel:${OTHER_ID}`;
@@ -119,8 +118,8 @@ describe("closeDiscordThreadSessions", () => {
});
expect(count).toBe(2);
expect(store[keyA].updatedAt).toBe(0);
expect(store[keyB].updatedAt).toBe(0);
expect(store[keyA]).toBeUndefined();
expect(store[keyB]).toBeUndefined();
expect(store[keyC].updatedAt).toBe(3_000);
});
@@ -156,9 +155,7 @@ describe("closeDiscordThreadSessions", () => {
});
expect(count).toBe(1);
expect(expectDefined(store[uppercaseKey], "uppercase Discord thread session").updatedAt).toBe(
0,
);
expect(store[uppercaseKey]).toBeUndefined();
});
it("returns 0 immediately when threadId is empty without touching the store", async () => {
@@ -170,28 +167,34 @@ describe("closeDiscordThreadSessions", () => {
expect(count).toBe(0);
expect(hoisted.listSessionEntries).not.toHaveBeenCalled();
expect(hoisted.patchSessionEntry).not.toHaveBeenCalled();
expect(hoisted.deleteSessionEntry).not.toHaveBeenCalled();
});
it("does not recount sessions that were already reset", async () => {
it("does not recount sessions that were already deleted", async () => {
const store = {
[MATCHED_KEY]: { updatedAt: 0 },
[MATCHED_KEY]: { updatedAt: 1_700_000_000_000 },
[UNMATCHED_KEY]: { updatedAt: 1_700_000_000_001 },
};
setupStore(store);
const count = await closeDiscordThreadSessions({
const firstCount = await closeDiscordThreadSessions({
cfg: {},
accountId: "default",
threadId: THREAD_ID,
});
const secondCount = await closeDiscordThreadSessions({
cfg: {},
accountId: "default",
threadId: THREAD_ID,
});
expect(count).toBe(0);
expect(store[MATCHED_KEY].updatedAt).toBe(0);
expect(firstCount).toBe(1);
expect(secondCount).toBe(0);
expect(store[MATCHED_KEY]).toBeUndefined();
expect(store[UNMATCHED_KEY].updatedAt).toBe(1_700_000_000_001);
});
it("does not reset a matching session that changed after the list snapshot", async () => {
it("does not delete a matching session that changed after the list snapshot", async () => {
const store = {
[MATCHED_KEY]: {
sessionId: "fresh-session",
@@ -1,20 +1,16 @@
// Discord plugin module implements thread session close behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
deleteSessionEntry,
listSessionEntries,
patchSessionEntry,
resolveStorePath,
} from "openclaw/plugin-sdk/session-store-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
/**
* Marks every session entry in the store whose key contains {@link threadId}
* as "reset" by setting `updatedAt` to 0.
*
* This mirrors how the daily / idle session reset works: zeroing `updatedAt`
* makes `evaluateSessionFreshness` treat the session as stale on the next
* inbound message, so the bot starts a fresh conversation without deleting
* any on-disk transcript history.
* Closes every session entry in the store whose key contains {@link threadId}.
* The explicit lifecycle deletion archives the old transcript and guarantees
* that a later inbound message starts a fresh session in every reset mode.
*/
export async function closeDiscordThreadSessions(params: {
cfg: OpenClawConfig;
@@ -49,28 +45,17 @@ export async function closeDiscordThreadSessions(params: {
let resetCount = 0;
for (const { sessionKey, entry } of listSessionEntries({ storePath })) {
if (!sessionKeyContainsThreadId(sessionKey) || entry.updatedAt === 0) {
if (!sessionKeyContainsThreadId(sessionKey)) {
continue;
}
// Setting updatedAt to 0 signals that this session is stale.
// evaluateSessionFreshness will create a new session on the next message.
let resetEntry = false;
await patchSessionEntry({
storePath,
const deleted = await deleteSessionEntry({
archiveTranscript: true,
expectedSessionId: entry.sessionId ?? null,
expectedUpdatedAt: entry.updatedAt,
sessionKey,
replaceEntry: true,
update: (current) => {
if (current.updatedAt === 0) {
return null;
}
if (current.updatedAt !== entry.updatedAt || current.sessionId !== entry.sessionId) {
return null;
}
resetEntry = true;
return { ...current, updatedAt: 0 };
},
storePath,
});
if (resetEntry) {
if (deleted) {
resetCount += 1;
}
}
@@ -53,7 +53,7 @@ describe("command resolveSession provider-owned daily reset", () => {
hoisted.terminalTranscriptNewer = false;
});
it("keeps a provider-owned CLI session across the default daily boundary", () => {
it("keeps a provider-owned CLI session with the default reset policy", () => {
const sessionKey = "agent:main:cli";
seedProviderOwned(sessionKey);
@@ -80,7 +80,7 @@ describe("command resolveSession provider-owned daily reset", () => {
};
const result = resolveSession({
cfg: { session: {} } as OpenClawConfig,
cfg: { session: { reset: { mode: "daily" } } } as OpenClawConfig,
sessionKey,
agentId: "main",
});
@@ -396,6 +396,7 @@ describe("session hook context wiring", () => {
sessionId: "daily-session",
text: "daily",
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
reset: { mode: "daily" },
});
const [event] = requireHookCall(hookRunnerMocks.runSessionEnd, "session_end");
+35 -35
View File
@@ -1258,7 +1258,7 @@ describe("initSessionState RawBody", () => {
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:discord:channel:daily-rollover";
const existingSessionId = "session-before-daily-reset";
// Stale under the default daily reset (atHour 4): started ~48h ago so
// Stale under the configured daily reset (atHour 4): started ~48h ago so
// sessionStartedAt < today's reset boundary.
const staleStartedAt = Date.now() - 48 * 60 * 60 * 1000;
@@ -1277,7 +1277,7 @@ describe("initSessionState RawBody", () => {
});
const cfg = {
session: { store: storePath },
session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
} as OpenClawConfig;
const result = await initSessionState({
@@ -1319,7 +1319,7 @@ describe("initSessionState RawBody", () => {
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:discord:channel:daily-rollover-behavior";
const existingSessionId = "session-before-daily-reset-behavior";
// Stale under the default daily reset (atHour 4): started ~48h ago.
// Stale under the configured daily reset (atHour 4): started ~48h ago.
const staleStartedAt = Date.now() - 48 * 60 * 60 * 1000;
await writeSessionStoreFast(storePath, {
@@ -1340,7 +1340,7 @@ describe("initSessionState RawBody", () => {
});
const cfg = {
session: { store: storePath },
session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
} as OpenClawConfig;
const result = await initSessionState({
@@ -1408,7 +1408,9 @@ describe("initSessionState RawBody", () => {
ChatType: "channel",
SessionKey: sessionKey,
},
cfg: { session: { store: storePath } } as OpenClawConfig,
cfg: {
session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
} as OpenClawConfig,
commandAuthorized: true,
});
@@ -1441,7 +1443,9 @@ describe("initSessionState RawBody", () => {
ChatType: "direct",
SessionKey: sessionKey,
},
cfg: { session: { store: storePath } } as OpenClawConfig,
cfg: {
session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
} as OpenClawConfig,
commandAuthorized: true,
});
@@ -1484,7 +1488,7 @@ describe("initSessionState RawBody", () => {
});
const cfg = {
session: { store: storePath },
session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
} as OpenClawConfig;
const result = await initSessionState({
@@ -1534,7 +1538,7 @@ describe("initSessionState RawBody", () => {
});
const cfg = {
session: { store: storePath },
session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
} as OpenClawConfig;
const result = await initSessionState({
@@ -2093,7 +2097,7 @@ describe("initSessionState reset policy", () => {
vi.useRealTimers();
});
it("defaults to daily reset at 4am local time", async () => {
it("keeps the current session across the former daily boundary by default", async () => {
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
const root = await makeCaseDir("openclaw-reset-daily-");
const storePath = path.join(root, "sessions.json");
@@ -2106,11 +2110,6 @@ describe("initSessionState reset policy", () => {
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
},
});
enqueueSystemEvent("stale daily rollover event", { sessionKey });
enqueueSystemEvent("stale daily rollover session-id event", {
sessionKey: existingSessionId,
});
const cfg = { session: { store: storePath } } as OpenClawConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
@@ -2118,21 +2117,12 @@ describe("initSessionState reset policy", () => {
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
expect(result.isNewSession).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
expect(clearBootstrapSnapshotOnSessionRolloverSpy).toHaveBeenCalledWith({
sessionKey,
previousSessionId: existingSessionId,
previousSessionId: undefined,
});
await expect(
drainFormattedSystemEvents({
cfg,
sessionKey,
isMainSession: false,
isNewSession: true,
}),
).resolves.toBeUndefined();
expect(peekSystemEvents(existingSessionId)).toStrictEqual([]);
});
it("treats sessions as stale before the daily reset when updated before yesterday's boundary", async () => {
@@ -2149,7 +2139,9 @@ describe("initSessionState reset policy", () => {
},
});
const cfg = { session: { store: storePath } } as OpenClawConfig;
const cfg = {
session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
} as OpenClawConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
@@ -2801,7 +2793,7 @@ describe("initSessionState reset policy", () => {
expect(result.sessionId).toBe(existingSessionId);
});
it("defaults to daily resets when only resetByType is configured", async () => {
it("keeps the no-reset default for types without an override", async () => {
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
const root = await makeCaseDir("openclaw-reset-type-default-");
const storePath = path.join(root, "sessions.json");
@@ -2827,8 +2819,8 @@ describe("initSessionState reset policy", () => {
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
expect(result.isNewSession).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
});
it("keeps legacy idleMinutes behavior without reset config", async () => {
@@ -4486,7 +4478,9 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
});
await fs.writeFile(transcriptPath, '{"type":"message"}\n', "utf8");
const cfg = { session: { store: storePath } } as OpenClawConfig;
const cfg = {
session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
Body: "hello",
@@ -4542,7 +4536,9 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
});
operation.setPhase("running");
const cfg = { session: { store: storePath } } as OpenClawConfig;
const cfg = {
session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
Body: "hello while active",
@@ -4598,7 +4594,9 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
resetTriggered: false,
});
const cfg = { session: { store: storePath } } as OpenClawConfig;
const cfg = {
session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
Body: "hello after boundary",
@@ -4655,7 +4653,9 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
});
operation.setPhase("running");
const cfg = { session: { store: storePath } } as OpenClawConfig;
const cfg = {
session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
Body: "hello after boundary",
@@ -4687,7 +4687,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
}
});
it("keeps provider-owned CLI sessions on implicit daily reset boundaries", async () => {
it("keeps provider-owned CLI sessions under the default no-reset policy", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
+2 -1
View File
@@ -513,7 +513,8 @@ describe("agentCommand", () => {
const store = path.join(home, "sessions.json");
const sessionKey = "agent:main:discord:channel:voice-1";
const staleStartedAt = Date.now() - 2 * 24 * 60 * 60_000;
mockConfig(home, store);
const cfg = mockConfig(home, store);
cfg.session = { ...cfg.session, reset: { mode: "daily" } };
await writeSessionStoreSeed(store, {
[sessionKey]: {
sessionId: "stale-voice-session",
+2 -2
View File
@@ -13,9 +13,9 @@ export const AUTOMATION_FIELD_HELP: Record<string, string> = {
"session.idleMinutes":
"Applies a legacy idle reset window in minutes for session reuse behavior across inactivity gaps. Use this only for compatibility and prefer structured reset policies under session.reset/session.resetByType.",
"session.reset":
"Defines the default reset policy object used when no type-specific or channel-specific override applies. Set this first, then layer resetByType or resetByChannel only where behavior must differ.",
"Defines the default reset policy object used when no type-specific or channel-specific override applies. By default sessions do not reset automatically; use daily or idle schedules to opt in, while /new and /reset remain available at any time.",
"session.reset.mode":
'Selects reset strategy: "daily" resets at a configured hour and "idle" resets after inactivity windows. Keep one clear mode per policy to avoid surprising context turnover patterns.',
'Selects reset strategy: "none" disables automatic reset (the default), "daily" resets at a configured hour, and "idle" resets after inactivity. /new and /reset remain available in every mode.',
"session.reset.atHour":
"Sets local-hour boundary (0-23) for daily reset mode so sessions roll over at predictable times. Use with mode=daily and align to operator timezone expectations for human-readable behavior.",
"session.reset.idleMinutes":
+3 -3
View File
@@ -37,7 +37,7 @@ describe("resolveSessionEntryResetFreshness", () => {
freshness: undefined,
resetType: "thread",
resetPolicy: {
mode: "daily",
mode: "none",
atHour: 4,
},
});
@@ -59,7 +59,7 @@ describe("resolveSessionEntryResetFreshness", () => {
const result = resolveSessionEntryResetFreshness({
sessionKey,
storePath,
sessionCfg: {},
sessionCfg: { reset: { mode: "daily" } },
resetType: "thread",
now,
});
@@ -253,7 +253,7 @@ describe("resolveSessionEntryResetFreshness", () => {
const result = resolveSessionEntryResetFreshness({
sessionKey,
storePath,
sessionCfg: {},
sessionCfg: { reset: { mode: "daily" } },
resetType: "thread",
now,
});
+185
View File
@@ -0,0 +1,185 @@
// Session reset policy tests cover defaults, opt-in schedules, and compatibility overrides.
import { describe, expect, it } from "vitest";
import { SessionSchema } from "../zod-schema.session.js";
import { evaluateSessionFreshness, resolveSessionResetPolicy } from "./reset-policy.js";
import { resolveChannelResetConfig } from "./reset.js";
const HOUR_MS = 60 * 60_000;
const DAY_MS = 24 * HOUR_MS;
describe("session reset policy", () => {
it.each([
{
name: "a long inactivity gap",
startedAt: new Date(2025, 0, 1, 12, 0, 0, 0).getTime(),
now: new Date(2026, 0, 1, 12, 0, 0, 0).getTime(),
},
{
name: "a midnight boundary",
startedAt: new Date(2026, 0, 17, 23, 0, 0, 0).getTime(),
now: new Date(2026, 0, 18, 5, 0, 0, 0).getTime(),
},
])("keeps the default policy fresh across $name", ({ startedAt, now }) => {
const policy = resolveSessionResetPolicy({ resetType: "direct" });
expect(policy.mode).toBe("none");
expect(
evaluateSessionFreshness({
updatedAt: startedAt,
sessionStartedAt: startedAt,
lastInteractionAt: startedAt,
now,
policy,
}),
).toEqual({ fresh: true });
});
it("honors a pending legacy reset tombstone under the default policy", () => {
const policy = resolveSessionResetPolicy({ resetType: "direct" });
expect(evaluateSessionFreshness({ updatedAt: 0, now: DAY_MS, policy })).toEqual({
fresh: false,
});
});
it("resets an explicit daily policy at its configured hour", () => {
const now = new Date(2026, 0, 18, 5, 0, 0, 0).getTime();
const startedAt = new Date(2026, 0, 18, 3, 0, 0, 0).getTime();
const policy = resolveSessionResetPolicy({
sessionCfg: { reset: { mode: "daily", atHour: 4 } },
resetType: "direct",
});
expect(
evaluateSessionFreshness({ updatedAt: startedAt, sessionStartedAt: startedAt, now, policy }),
).toMatchObject({ fresh: false, staleReason: "daily" });
});
it.each([
{
name: "the base reset",
sessionCfg: { reset: { atHour: 6 } },
resetType: "direct" as const,
},
{
name: "a type override",
sessionCfg: { resetByType: { group: { atHour: 6 } } },
resetType: "group" as const,
},
{
name: "a type override above a disabled base policy",
sessionCfg: {
reset: { mode: "none" as const },
resetByType: { group: { atHour: 6 } },
},
resetType: "group" as const,
},
])("preserves the daily fallback when $name omits mode", ({ sessionCfg, resetType }) => {
expect(resolveSessionResetPolicy({ sessionCfg, resetType })).toMatchObject({
mode: "daily",
atHour: 6,
});
});
it("preserves combined daily and idle expiry when an explicit reset omits mode", () => {
expect(
resolveSessionResetPolicy({
sessionCfg: { reset: { idleMinutes: 30 } },
resetType: "direct",
}),
).toMatchObject({ mode: "daily", idleMinutes: 30 });
});
it("inherits an active base mode for partial type overrides", () => {
expect(
resolveSessionResetPolicy({
sessionCfg: {
reset: { mode: "idle", idleMinutes: 60 },
resetByType: { group: { idleMinutes: 30 } },
},
resetType: "group",
}),
).toMatchObject({ mode: "idle", idleMinutes: 30 });
});
it("expires an explicit idle policy after inactivity", () => {
const now = 10 * HOUR_MS;
const lastInteractionAt = now - 31 * 60_000;
const policy = resolveSessionResetPolicy({
sessionCfg: { reset: { mode: "idle", idleMinutes: 30 } },
resetType: "direct",
});
expect(
evaluateSessionFreshness({ updatedAt: now, lastInteractionAt, now, policy }),
).toMatchObject({ fresh: false, staleReason: "idle" });
});
it("keeps legacy idleMinutes as an idle reset policy", () => {
const now = 10 * HOUR_MS;
const policy = resolveSessionResetPolicy({
sessionCfg: { idleMinutes: 30 },
resetType: "direct",
});
expect(policy).toMatchObject({ mode: "idle", idleMinutes: 30, configured: true });
expect(evaluateSessionFreshness({ updatedAt: now - DAY_MS, now, policy })).toMatchObject({
fresh: false,
staleReason: "idle",
});
});
it("applies resetByType only to the matching session type", () => {
const sessionCfg = {
resetByType: { group: { mode: "idle" as const, idleMinutes: 30 } },
};
expect(resolveSessionResetPolicy({ sessionCfg, resetType: "direct" }).mode).toBe("none");
expect(resolveSessionResetPolicy({ sessionCfg, resetType: "group" })).toMatchObject({
mode: "idle",
idleMinutes: 30,
});
});
it("applies a resetByChannel override ahead of the default policy", () => {
const sessionCfg = {
resetByChannel: { discord: { mode: "daily" as const, atHour: 6 } },
};
const resetOverride = resolveChannelResetConfig({ sessionCfg, channel: "discord" });
expect(
resolveSessionResetPolicy({ sessionCfg, resetType: "direct", resetOverride }),
).toMatchObject({ mode: "daily", atHour: 6, configured: true });
const modeLessSessionCfg = {
reset: { mode: "none" as const },
resetByChannel: { discord: { atHour: 7 } },
};
const modeLessOverride = resolveChannelResetConfig({
sessionCfg: modeLessSessionCfg,
channel: "discord",
});
expect(
resolveSessionResetPolicy({
sessionCfg: modeLessSessionCfg,
resetType: "direct",
resetOverride: modeLessOverride,
}),
).toMatchObject({ mode: "daily", atHour: 7, configured: true });
});
it("accepts none in the session schema and ignores reset deadlines", () => {
const sessionCfg = SessionSchema.parse({
reset: { mode: "none", atHour: 4, idleMinutes: 30 },
});
const policy = resolveSessionResetPolicy({
sessionCfg: { reset: sessionCfg?.reset },
resetType: "direct",
});
expect(evaluateSessionFreshness({ updatedAt: 1, now: DAY_MS, policy })).toEqual({
fresh: true,
});
});
});
+21 -5
View File
@@ -1,8 +1,9 @@
// Session reset policy resolves daily/idle freshness for direct, group, and thread sessions.
// Session reset policy resolves automatic freshness for direct, group, and thread sessions.
import type { SessionConfig, SessionResetConfig } from "../types.base.js";
import { DEFAULT_IDLE_MINUTES } from "./types.js";
export type SessionResetMode = "daily" | "idle";
export type SessionResetMode = "none" | "daily" | "idle";
type SessionStaleReason = Exclude<SessionResetMode, "none">;
export type SessionResetType = "direct" | "group" | "thread";
export type SessionResetPolicy = {
@@ -16,10 +17,10 @@ export type SessionFreshness = {
fresh: boolean;
dailyResetAt?: number;
idleExpiresAt?: number;
staleReason?: SessionResetMode;
staleReason?: SessionStaleReason;
};
const DEFAULT_RESET_MODE: SessionResetMode = "daily";
const DEFAULT_RESET_MODE: SessionResetMode = "none";
const DEFAULT_RESET_AT_HOUR = 4;
/** Returns the most recent daily reset boundary for the supplied wall-clock time. */
@@ -53,10 +54,17 @@ export function resolveSessionResetPolicy(params: {
const legacyIdleMinutes = params.resetOverride ? undefined : sessionCfg?.idleMinutes;
const configured = Boolean(baseReset || typeReset || legacyIdleMinutes != null);
// Legacy `idleMinutes` implied idle reset only when no modern reset block was configured.
const inheritedTypeMode = typeReset && baseReset?.mode !== "none" ? baseReset?.mode : undefined;
const mode =
typeReset?.mode ??
inheritedTypeMode ??
(typeReset ? "daily" : undefined) ??
baseReset?.mode ??
(!hasExplicitReset && legacyIdleMinutes != null ? "idle" : DEFAULT_RESET_MODE);
(baseReset
? "daily"
: !hasExplicitReset && legacyIdleMinutes != null
? "idle"
: DEFAULT_RESET_MODE);
const atHour = normalizeResetAtHour(
typeReset?.atHour ?? baseReset?.atHour ?? DEFAULT_RESET_AT_HOUR,
);
@@ -83,6 +91,14 @@ export function evaluateSessionFreshness(params: {
now: number;
policy: SessionResetPolicy;
}): SessionFreshness {
// Older releases persisted updatedAt=0 as an explicit pending reset marker.
// Honor that one-time tombstone even when automatic resets are disabled.
if (params.updatedAt === 0) {
return { fresh: false };
}
if (params.policy.mode === "none") {
return { fresh: true };
}
const updatedAt = resolveTimestamp(params.updatedAt, params.now) ?? 0;
const sessionStartedAt = resolveTimestamp(params.sessionStartedAt, params.now) ?? updatedAt;
const lastInteractionAt =
@@ -267,7 +267,7 @@ export type DeleteSessionEntryLifecycleParams = {
agentId?: string;
archiveTranscript: boolean;
expectedEntry?: SessionEntry;
expectedSessionId?: string;
expectedSessionId?: string | null;
expectedLifecycleRevision?: string;
expectedUpdatedAt?: number;
storePath: string;
@@ -367,7 +367,12 @@ function shouldDeleteSqliteSessionEntryLifecycle(
) {
return false;
}
if (params.expectedSessionId !== undefined && entry.sessionId !== params.expectedSessionId) {
if (
params.expectedSessionId !== undefined &&
(params.expectedSessionId === null
? entry.sessionId !== undefined
: entry.sessionId !== params.expectedSessionId)
) {
return false;
}
if (
@@ -854,7 +854,7 @@ export type DeleteSessionEntryLifecycleParams = {
/** Optional exact row guard checked under the storage writer lock. */
expectedEntry?: SessionEntry;
/** Optional provider-run identity guard checked under the storage writer lock. */
expectedSessionId?: string;
expectedSessionId?: string | null;
/** Optional owner revision guard checked under the storage writer lock. */
expectedLifecycleRevision?: string;
/** Optional persisted revision guard checked under the storage writer lock. */
+3 -3
View File
@@ -152,16 +152,16 @@ describe("resolveSessionResetPolicy", () => {
resetType: "group",
});
expect(groupPolicy.mode).toBe("daily");
expect(groupPolicy.mode).toBe("none");
});
});
it("defaults to daily resets at 4am local time", () => {
it("defaults to no automatic reset", () => {
const policy = resolveSessionResetPolicy({
resetType: "direct",
});
expect(policy.mode).toBe("daily");
expect(policy.mode).toBe("none");
expect(policy.atHour).toBe(4);
});
+8 -6
View File
@@ -1069,7 +1069,7 @@ type DeleteSessionEntryLifecycleParams = {
archiveTranscript: boolean;
expectedEntry?: SessionEntry;
expectedLifecycleRevision?: string;
expectedSessionId?: string;
expectedSessionId?: string | null;
expectedUpdatedAt?: number;
requireWriteSuccess?: boolean;
storePath: string;
@@ -1101,11 +1101,13 @@ async function deleteSessionEntryLifecycleInternal(
params.expectedLifecycleRevision === undefined ||
deletedEntry.lifecycleRevision === params.expectedLifecycleRevision;
const expectedSessionIdMatches =
!params.expectedSessionId ||
deletedEntry.sessionId === params.expectedSessionId ||
(deletedEntry.sessionId === undefined &&
params.expectedLifecycleRevision !== undefined &&
expectedLifecycleRevisionMatches);
params.expectedSessionId === undefined ||
(params.expectedSessionId === null
? deletedEntry.sessionId === undefined
: deletedEntry.sessionId === params.expectedSessionId ||
(deletedEntry.sessionId === undefined &&
params.expectedLifecycleRevision !== undefined &&
expectedLifecycleRevisionMatches));
const expectedUpdatedAtMatches =
params.expectedUpdatedAt === undefined || deletedEntry.updatedAt === params.expectedUpdatedAt;
if (
+1 -1
View File
@@ -170,7 +170,7 @@ export type SessionSendPolicyConfig = {
rules?: SessionSendPolicyRule[];
};
export type SessionResetMode = "daily" | "idle";
export type SessionResetMode = "none" | "daily" | "idle";
export type SessionResetConfig = {
mode?: SessionResetMode;
/** Local hour (0-23) for the daily reset boundary. */
+1 -1
View File
@@ -18,7 +18,7 @@ import { sensitive } from "./zod-schema.sensitive.js";
const SessionResetConfigSchema = z
.object({
mode: z.union([z.literal("daily"), z.literal("idle")]).optional(),
mode: z.union([z.literal("none"), z.literal("daily"), z.literal("idle")]).optional(),
atHour: z.number().int().min(0).max(23).optional(),
idleMinutes: z.number().int().positive().optional(),
})
@@ -21,7 +21,7 @@ function providerOwnedEntry(): SessionEntry {
}
describe("resolveCronSession provider-owned daily reset", () => {
it("keeps a provider-owned CLI session across the default daily boundary", () => {
it("keeps a provider-owned CLI session with the default reset policy", () => {
const sessionKey = "agent:main:cron:daily-job";
const entry = providerOwnedEntry();
@@ -52,7 +52,7 @@ describe("resolveCronSession provider-owned daily reset", () => {
};
const result = resolveCronSession({
cfg: { session: {} } as OpenClawConfig,
cfg: { session: { reset: { mode: "daily" } } } as OpenClawConfig,
sessionKey,
agentId: "main",
nowMs: NOW_MS,
@@ -936,6 +936,53 @@ describe("session-store-runtime compatibility surface", () => {
expect(getSessionEntry({ sessionKey, storePath })).toBeUndefined();
});
it("guards entry deletion against a concurrent session update", async () => {
const sessionKey = "agent:main:delete-guarded";
const updatedAt = Date.now();
await seedSessionEntry(sessionKey, { sessionId: "session-delete-guarded", updatedAt });
await expect(
deleteSessionEntry({
expectedSessionId: "older-session",
expectedUpdatedAt: updatedAt - 1,
sessionKey,
storePath,
}),
).resolves.toBe(false);
expect(getSessionEntry({ sessionKey, storePath })).toMatchObject({
sessionId: "session-delete-guarded",
updatedAt,
});
await expect(
deleteSessionEntry({
expectedSessionId: "session-delete-guarded",
expectedUpdatedAt: updatedAt,
sessionKey,
storePath,
}),
).resolves.toBe(true);
});
it("guards entry deletion when the earlier snapshot had no session id", async () => {
const sessionKey = "agent:main:delete-guarded-absent-id";
const updatedAt = Date.now();
await seedSessionEntry(sessionKey, { sessionId: "replacement-session", updatedAt });
await expect(
deleteSessionEntry({
expectedSessionId: null,
expectedUpdatedAt: updatedAt,
sessionKey,
storePath,
}),
).resolves.toBe(false);
expect(getSessionEntry({ sessionKey, storePath })).toMatchObject({
sessionId: "replacement-session",
updatedAt,
});
});
it("resolves agent-scoped custom SQLite stores for backups", () => {
const customStorePath = path.join(tempDir, "custom", "sessions.json");
+11 -2
View File
@@ -113,6 +113,8 @@ type ReadAmbientTranscriptWatermarkParams = SessionStoreReadParams & {
type DeleteSessionEntryParams = SessionStoreReadParams & {
archiveTranscript?: boolean;
expectedSessionId?: string | null;
expectedUpdatedAt?: number;
};
type SessionLifecycleArtifactsCleanupParams = {
@@ -494,15 +496,22 @@ export async function upsertSessionEntry(params: UpsertSessionEntryParams): Prom
/** Deletes one session entry by agent/session identity. */
export async function deleteSessionEntry(params: DeleteSessionEntryParams): Promise<boolean> {
const agentId = params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey);
const storePath =
params.storePath ??
resolveSessionStorePath(undefined, {
agentId: params.agentId,
agentId,
env: params.env,
});
const result = await deleteAccessorSessionEntryLifecycle({
...(params.agentId !== undefined ? { agentId: params.agentId } : {}),
...(agentId !== undefined ? { agentId } : {}),
archiveTranscript: params.archiveTranscript ?? false,
...(params.expectedSessionId !== undefined
? { expectedSessionId: params.expectedSessionId }
: {}),
...(params.expectedUpdatedAt !== undefined
? { expectedUpdatedAt: params.expectedUpdatedAt }
: {}),
storePath,
target: {
canonicalKey: params.sessionKey,