From 566d2d73a323708df467a4f6645e0b882235b287 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 02:12:21 +0100 Subject: [PATCH] fix: keep system events from extending session resets (#71845) --- CHANGELOG.md | 5 + docs/automation/cron-jobs.md | 14 +- docs/automation/index.md | 2 +- docs/concepts/session.md | 22 ++- docs/gateway/config-agents.md | 2 +- docs/gateway/heartbeat.md | 5 +- .../session-management-compaction.md | 10 +- src/agents/agent-command.ts | 22 ++- src/agents/command/session-store.test.ts | 83 +++++++++++ src/agents/command/session-store.ts | 10 +- src/agents/command/session.ts | 13 +- .../reply/agent-runner-session-reset.ts | 5 +- src/auto-reply/reply/commands-reset.ts | 10 +- src/auto-reply/reply/get-reply-fast-path.ts | 2 + .../reply/session.heartbeat-no-reset.test.ts | 141 +++++++++++++++++- src/auto-reply/reply/session.ts | 25 +++- src/config/sessions.ts | 1 + src/config/sessions/lifecycle.ts | 114 ++++++++++++++ src/config/sessions/reset-policy.ts | 12 +- src/config/sessions/session-file.ts | 6 +- src/config/sessions/sessions.test.ts | 89 +++++++++++ src/config/sessions/types.ts | 21 ++- src/cron/isolated-agent/session.test.ts | 5 + src/cron/isolated-agent/session.ts | 15 ++ src/gateway/server-methods/agent.test.ts | 123 +++++++++++++++ src/gateway/server-methods/agent.ts | 52 ++++++- 26 files changed, 775 insertions(+), 34 deletions(-) create mode 100644 src/config/sessions/lifecycle.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a493b4639..83e157d73df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,6 +159,11 @@ Docs: https://docs.openclaw.ai - Image understanding: resolve configured image models such as local LM Studio vision entries before reporting `Unknown model` when the discovery registry has not registered that provider. Fixes #66486. Thanks @zhanggpcsu. +- Sessions: separate reset freshness from session-store `updatedAt`, so + heartbeat, cron, exec, and gateway bookkeeping no longer prevent configured + daily/idle resets from rolling long-running channel sessions. Fixes #68315, + #63732, #63820, and #69083. Thanks @maxatv, @longhairedsi, @bradfreels, + and @akessel56. - CLI/agents: keep `agents bind`, `agents unbind`, and `agents bindings` on setup-safe channel metadata paths so they do not preload bundled plugin runtimes or stage runtime dependencies. Fixes #71743. diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 90f4e1e4716..40a16c38581 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -84,7 +84,7 @@ This fires ~5–6 times per month instead of 0–1 times per month. OpenClaw use | Current session | `current` | Bound at creation time | Context-aware recurring work | | Custom session | `session:custom-id` | Persistent named session | Workflows that build on history | -**Main session** jobs enqueue a system event and optionally wake the heartbeat (`--wake now` or `--wake next-heartbeat`). **Isolated** jobs run a dedicated agent turn with a fresh session. **Custom sessions** (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries. +**Main session** jobs enqueue a system event and optionally wake the heartbeat (`--wake now` or `--wake next-heartbeat`). Those system events do not extend daily/idle reset freshness for the target session. **Isolated** jobs run a dedicated agent turn with a fresh session. **Custom sessions** (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries. For isolated jobs, “fresh session” means a new transcript/session id for each run. OpenClaw may carry safe preferences such as thinking/fast/verbose settings, labels, and explicit user-selected model/auth overrides, but it does not inherit ambient conversation context from an older cron row: channel/group routing, send or queue policy, elevation, origin, or ACP runtime binding. Use `current` or `session:` when a recurring job should deliberately build on the same conversation context. @@ -433,6 +433,18 @@ openclaw doctor - If the agent should message the user itself, check that the job has a usable route (`channel: "last"` with a previous chat, or an explicit channel/target). +### Cron or heartbeat appears to prevent `/new`-style rollover + +- Daily and idle reset freshness is not based on `updatedAt`; see + [Session management](/concepts/session#session-lifecycle). +- Cron wakeups, heartbeat runs, exec notifications, and gateway bookkeeping may + update the session row for routing/status, but they do not extend + `sessionStartedAt` or `lastInteractionAt`. +- For legacy rows created before those fields existed, OpenClaw can recover + `sessionStartedAt` from the transcript JSONL session header when the file is + still available. Legacy idle rows without `lastInteractionAt` use that + recovered start time as their idle baseline. + ### Timezone gotchas - Cron without `--tz` uses the gateway host timezone. diff --git a/docs/automation/index.md b/docs/automation/index.md index bd0fed50685..cd8b49a330a 100644 --- a/docs/automation/index.md +++ b/docs/automation/index.md @@ -93,7 +93,7 @@ See [Hooks](/automation/hooks). ### Heartbeat -Heartbeat is a periodic main-session turn (default every 30 minutes). It batches multiple checks (inbox, calendar, notifications) in one agent turn with full session context. Heartbeat turns do not create task records. Use `HEARTBEAT.md` for a small checklist, or a `tasks:` block when you want due-only periodic checks inside heartbeat itself. Empty heartbeat files skip as `empty-heartbeat-file`; due-only task mode skips as `no-tasks-due`. +Heartbeat is a periodic main-session turn (default every 30 minutes). It batches multiple checks (inbox, calendar, notifications) in one agent turn with full session context. Heartbeat turns do not create task records and do not extend daily/idle session reset freshness. Use `HEARTBEAT.md` for a small checklist, or a `tasks:` block when you want due-only periodic checks inside heartbeat itself. Empty heartbeat files skip as `empty-heartbeat-file`; due-only task mode skips as `no-tasks-due`. See [Heartbeat](/gateway/heartbeat). diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 3e41fcf4a7b..9def682fdbb 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -3,6 +3,7 @@ summary: "How OpenClaw manages conversation sessions" read_when: - You want to understand session routing and isolation - You want to configure DM scope for multi-user setups + - You are debugging daily or idle session resets title: "Session management" --- @@ -59,13 +60,18 @@ Verify your setup with `openclaw security audit`. Sessions are reused until they expire: - **Daily reset** (default) -- new session at 4:00 AM local time on the gateway - host. + host. Daily freshness is based on when the current `sessionId` started, not + on later metadata writes. - **Idle reset** (optional) -- new session after a period of inactivity. Set - `session.reset.idleMinutes`. + `session.reset.idleMinutes`. Idle freshness is based on the last real + user/channel interaction, so heartbeat, cron, and exec system events do not + keep the session alive. - **Manual reset** -- type `/new` or `/reset` in chat. `/new ` also switches the model. When both daily and idle resets are configured, whichever expires first wins. +Heartbeat, cron, exec, and other system-event turns may write session metadata, +but those writes do not extend daily or idle reset freshness. 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 @@ -79,6 +85,18 @@ session data. - **Store:** `~/.openclaw/agents//sessions/sessions.json` - **Transcripts:** `~/.openclaw/agents//sessions/.jsonl` +`sessions.json` keeps separate lifecycle timestamps: + +- `sessionStartedAt`: when the current `sessionId` began; daily reset uses this. +- `lastInteractionAt`: last user/channel interaction that extends idle lifetime. +- `updatedAt`: last store-row mutation; useful for listing and pruning, but not + authoritative for daily/idle reset freshness. + +Older rows without `sessionStartedAt` are resolved from the transcript JSONL +session header when available. If an older row also lacks `lastInteractionAt`, +idle freshness falls back to that session start time, not to later bookkeeping +writes. + ## Session maintenance OpenClaw automatically bounds session storage over time. By default, it runs diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 097611f5f29..521dad4980f 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -1162,7 +1162,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. -- **`reset`**: primary reset policy. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins. +- **`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. - **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`. - **`parentForkMaxTokens`**: max parent-session `totalTokens` allowed when creating a forked thread session (default `100000`). - If parent `totalTokens` is above this value, OpenClaw starts a fresh thread session instead of inheriting parent transcript history. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 008c44782ad..4b07bc83b23 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -263,8 +263,9 @@ Use `accountId` to target a specific account on multi-account channels like Tele - If the resolved heartbeat target supports typing, OpenClaw shows typing while the heartbeat run is active. This uses the same target the heartbeat would send chat output to, and it is disabled by `typingMode: "never"`. -- Heartbeat-only replies do **not** keep the session alive; the last `updatedAt` - is restored so idle expiry behaves normally. +- Heartbeat-only replies do **not** keep the session alive. Heartbeat metadata + may update the session row, but idle expiry uses `lastInteractionAt` from the + last real user/channel message, and daily expiry uses `sessionStartedAt`. - Control UI and WebChat history hide heartbeat prompts and OK-only acknowledgments. The underlying session transcript can still contain those turns for audit/replay. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 016adf2ab86..d38dd6f2531 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -136,6 +136,7 @@ Rules of thumb: - **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. When daily + idle are both configured, whichever expires first wins. +- **System events** (heartbeat, cron wakeups, exec notifications, gateway bookkeeping) may mutate the session row but do not extend daily/idle reset freshness. - **Thread parent fork guard** (`session.parentForkMaxTokens`, default `100000`) skips parent transcript forking when the parent session is already too large; the new thread starts fresh. Set `0` to disable. Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`. @@ -149,7 +150,14 @@ The store’s value type is `SessionEntry` in `src/config/sessions.ts`. Key fields (not exhaustive): - `sessionId`: current transcript id (filename is derived from this unless `sessionFile` is set) -- `updatedAt`: last activity timestamp +- `sessionStartedAt`: start timestamp for the current `sessionId`; daily reset + freshness uses this. Legacy rows may derive it from the JSONL session header. +- `lastInteractionAt`: last real user/channel interaction timestamp; idle reset + 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 + 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) - `provider`, `subject`, `room`, `space`, `displayName`: metadata for group/channel labeling diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 88b7d393987..41e6cead723 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -616,14 +616,17 @@ async function agentCommandInternal( : currentSkillsSnapshot; if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) { + const now = Date.now(); const current = sessionEntry ?? { sessionId, - updatedAt: Date.now(), + updatedAt: now, + sessionStartedAt: now, }; const next: SessionEntry = { ...current, sessionId, - updatedAt: Date.now(), + updatedAt: now, + sessionStartedAt: current.sessionStartedAt ?? now, skillsSnapshot, }; await persistSessionEntry({ @@ -637,9 +640,16 @@ async function agentCommandInternal( // Persist explicit /command overrides to the session store when we have a key. if (sessionStore && sessionKey) { + const now = Date.now(); const entry = sessionStore[sessionKey] ?? - sessionEntry ?? { sessionId, updatedAt: Date.now() }; - const next: SessionEntry = { ...entry, sessionId, updatedAt: Date.now() }; + sessionEntry ?? { sessionId, updatedAt: now, sessionStartedAt: now }; + const next: SessionEntry = { + ...entry, + sessionId, + updatedAt: now, + sessionStartedAt: entry.sessionStartedAt ?? now, + lastInteractionAt: now, + }; if (thinkOverride) { next.thinkingLevel = thinkOverride; } @@ -1064,6 +1074,10 @@ async function agentCommandInternal( fallbackProvider, fallbackModel, result, + touchInteraction: + opts.bootstrapContextRunKind !== "cron" && + opts.bootstrapContextRunKind !== "heartbeat" && + !opts.internalEvents?.length, }); sessionEntry = sessionStore[sessionKey] ?? sessionEntry; } diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 5b39d1f7fdd..43a8e6e1567 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -648,6 +648,89 @@ describe("updateSessionStoreAfterAgentRun", () => { expect(persisted[sessionKey]?.estimatedCostUsd).toBeCloseTo(0.25, 4); }); }); + + it("preserves lastInteractionAt for non-interactive system runs", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-system-run"; + const sessionId = "test-system-run-session"; + const lastInteractionAt = Date.now() - 60 * 60_000; + const sessionStartedAt = Date.now() - 2 * 60 * 60_000; + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: Date.now() - 10_000, + sessionStartedAt, + lastInteractionAt, + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "openai", + defaultModel: "gpt-5.4", + result: { + meta: { + durationMs: 1, + agentMeta: { + sessionId, + provider: "openai", + model: "gpt-5.4", + }, + }, + }, + touchInteraction: false, + }); + + expect(sessionStore[sessionKey]?.lastInteractionAt).toBe(lastInteractionAt); + expect(sessionStore[sessionKey]?.sessionStartedAt).toBe(sessionStartedAt); + expect(sessionStore[sessionKey]?.updatedAt).toBeGreaterThan(lastInteractionAt); + }); + }); + + it("advances lastInteractionAt for interactive runs", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-user-run"; + const sessionId = "test-user-run-session"; + const lastInteractionAt = Date.now() - 60 * 60_000; + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: Date.now() - 10_000, + lastInteractionAt, + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "openai", + defaultModel: "gpt-5.4", + result: { + meta: { + durationMs: 1, + agentMeta: { + sessionId, + provider: "openai", + model: "gpt-5.4", + }, + }, + }, + }); + + expect(sessionStore[sessionKey]?.lastInteractionAt).toBeGreaterThan(lastInteractionAt); + }); + }); }); describe("clearCliSessionInStore", () => { diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 397c287d342..8508176069c 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -49,6 +49,7 @@ export async function updateSessionStoreAfterAgentRun(params: { fallbackProvider?: string; fallbackModel?: string; result: RunResult; + touchInteraction?: boolean; }) { const { cfg, @@ -62,6 +63,8 @@ export async function updateSessionStoreAfterAgentRun(params: { fallbackModel, result, } = params; + const now = Date.now(); + const touchInteraction = params.touchInteraction !== false; const usage = result.meta.agentMeta?.usage; const promptTokens = result.meta.agentMeta?.promptTokens; @@ -85,12 +88,15 @@ export async function updateSessionStoreAfterAgentRun(params: { const entry = sessionStore[sessionKey] ?? { sessionId, - updatedAt: Date.now(), + updatedAt: now, + sessionStartedAt: now, }; const next: SessionEntry = { ...entry, sessionId, - updatedAt: Date.now(), + updatedAt: now, + sessionStartedAt: entry.sessionId === sessionId ? (entry.sessionStartedAt ?? now) : now, + lastInteractionAt: touchInteraction ? now : entry.lastInteractionAt, contextTokens, }; setSessionRuntimeModel(next, { diff --git a/src/agents/command/session.ts b/src/agents/command/session.ts index c67cd56ab3a..494a9e68113 100644 --- a/src/agents/command/session.ts +++ b/src/agents/command/session.ts @@ -6,6 +6,7 @@ import { type ThinkLevel, type VerboseLevel, } from "../../auto-reply/thinking.js"; +import { resolveSessionLifecycleTimestamps } from "../../config/sessions/lifecycle.js"; import { resolveAgentIdFromSessionKey, resolveExplicitAgentSessionKey, @@ -235,8 +236,16 @@ export function resolveSession(opts: { resetOverride: channelReset, }); const fresh = sessionEntry - ? evaluateSessionFreshness({ updatedAt: sessionEntry.updatedAt, now, policy: resetPolicy }) - .fresh + ? evaluateSessionFreshness({ + updatedAt: sessionEntry.updatedAt, + ...resolveSessionLifecycleTimestamps({ + entry: sessionEntry, + agentId: opts.agentId, + storePath, + }), + now, + policy: resetPolicy, + }).fresh : false; const sessionId = opts.sessionId?.trim() || (fresh ? sessionEntry?.sessionId : undefined) || crypto.randomUUID(); diff --git a/src/auto-reply/reply/agent-runner-session-reset.ts b/src/auto-reply/reply/agent-runner-session-reset.ts index dbb6dd8c017..3a7eaed9601 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.ts @@ -55,10 +55,13 @@ export async function resetReplyRunSession(params: { } const prevSessionId = params.options.cleanupTranscripts ? prevEntry.sessionId : undefined; const nextSessionId = deps.generateSecureUuid(); + const now = Date.now(); const nextEntry: SessionEntry = { ...prevEntry, sessionId: nextSessionId, - updatedAt: Date.now(), + updatedAt: now, + sessionStartedAt: now, + lastInteractionAt: now, systemSent: false, abortedLastRun: false, modelProvider: undefined, diff --git a/src/auto-reply/reply/commands-reset.ts b/src/auto-reply/reply/commands-reset.ts index 16e086d40be..920a7ebcebf 100644 --- a/src/auto-reply/reply/commands-reset.ts +++ b/src/auto-reply/reply/commands-reset.ts @@ -57,15 +57,18 @@ export async function maybeHandleResetCommand( const previousSessionEntry = params.previousSessionEntry ?? (targetSessionEntry ? { ...targetSessionEntry } : undefined); if (targetSessionEntry) { + const now = Date.now(); clearAllCliSessions(targetSessionEntry); if (params.sessionEntry && params.sessionEntry !== targetSessionEntry) { clearAllCliSessions(params.sessionEntry); - params.sessionEntry.updatedAt = Date.now(); + params.sessionEntry.updatedAt = now; + params.sessionEntry.lastInteractionAt = now; } if (params.sessionKey) { clearBootstrapSnapshot(params.sessionKey); } - targetSessionEntry.updatedAt = Date.now(); + targetSessionEntry.updatedAt = now; + targetSessionEntry.lastInteractionAt = now; if (params.sessionStore && params.sessionKey) { params.sessionStore[params.sessionKey] = targetSessionEntry; } @@ -80,7 +83,8 @@ export async function maybeHandleResetCommand( cliSessionBindings: next.cliSessionBindings, cliSessionIds: next.cliSessionIds, claudeCliSessionId: next.claudeCliSessionId, - updatedAt: Date.now(), + updatedAt: now, + lastInteractionAt: now, }; }, }); diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts index 9162a8e2163..e5790d55f54 100644 --- a/src/auto-reply/reply/get-reply-fast-path.ts +++ b/src/auto-reply/reply/get-reply-fast-path.ts @@ -243,6 +243,8 @@ export function initFastReplySessionState(params: { sessionId, sessionFile, updatedAt: now, + sessionStartedAt: resetTriggered ? now : (existingEntry?.sessionStartedAt ?? now), + lastInteractionAt: now, thinkingLevel: resetTriggered ? existingEntry?.thinkingLevel : existingEntry?.thinkingLevel, verboseLevel: resetTriggered ? existingEntry?.verboseLevel : existingEntry?.verboseLevel, reasoningLevel: resetTriggered ? existingEntry?.reasoningLevel : existingEntry?.reasoningLevel, diff --git a/src/auto-reply/reply/session.heartbeat-no-reset.test.ts b/src/auto-reply/reply/session.heartbeat-no-reset.test.ts index 55ac78c19b3..7dc239864fb 100644 --- a/src/auto-reply/reply/session.heartbeat-no-reset.test.ts +++ b/src/auto-reply/reply/session.heartbeat-no-reset.test.ts @@ -2,7 +2,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { saveSessionStore } from "../../config/sessions/store.js"; +import { loadSessionStore, saveSessionStore } from "../../config/sessions/store.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; import type { MsgContext } from "../templating.js"; import { initSessionState } from "./session.js"; @@ -62,12 +63,17 @@ describe("initSessionState - heartbeat should not trigger session reset", () => ...overrides, }); - const saveExistingSession = async (sessionId: string, updatedAt: number): Promise => { + const saveExistingSession = async ( + sessionId: string, + updatedAt: number, + overrides: Partial = {}, + ): Promise => { await saveSessionStore(storePath, { "main:user123": { sessionId, updatedAt, systemSent: true, + ...overrides, }, }); }; @@ -152,6 +158,137 @@ describe("initSessionState - heartbeat should not trigger session reset", () => expect(result.sessionId).toBe("original-session-id-67890"); }); + it("does not let heartbeat keep an expired daily session fresh for the next user message", async () => { + const now = Date.now(); + const staleTime = now - 25 * 60 * 60 * 1000; + + await saveExistingSession("daily-session-id", now, { + sessionStartedAt: staleTime, + lastInteractionAt: staleTime, + }); + + const cfg = createBaseConfig(); + cfg.session!.reset = { + mode: "daily", + atHour: 4, + }; + + const heartbeatResult = await initSessionState({ + ctx: createBaseCtx({ + Provider: "heartbeat", + Body: "HEARTBEAT_OK", + }), + cfg, + commandAuthorized: true, + }); + + expect(heartbeatResult.isNewSession).toBe(false); + expect(heartbeatResult.sessionId).toBe("daily-session-id"); + expect(heartbeatResult.sessionEntry.lastInteractionAt).toBe(staleTime); + + const persistedAfterHeartbeat = loadSessionStore(storePath); + expect(persistedAfterHeartbeat["main:user123"]?.lastInteractionAt).toBe(staleTime); + + const userResult = await initSessionState({ + ctx: createBaseCtx({ + Provider: "quietchat", + Body: "real user message", + }), + cfg, + commandAuthorized: true, + }); + + expect(userResult.isNewSession).toBe(true); + expect(userResult.sessionId).not.toBe("daily-session-id"); + }); + + it("resets legacy daily sessions using the JSONL header even when updatedAt is fresh", async () => { + const now = Date.now(); + const staleTime = now - 25 * 60 * 60 * 1000; + const sessionFile = path.join(tempDir, "legacy-daily-session.jsonl"); + await fs.writeFile( + sessionFile, + `${JSON.stringify({ + type: "session", + version: 3, + id: "legacy-daily-session", + timestamp: new Date(staleTime).toISOString(), + cwd: tempDir, + })}\n`, + "utf8", + ); + await saveExistingSession("legacy-daily-session", now, { + sessionFile, + lastInteractionAt: staleTime, + }); + + const cfg = createBaseConfig(); + cfg.session!.reset = { + mode: "daily", + atHour: 4, + }; + + const result = await initSessionState({ + ctx: createBaseCtx({ + Provider: "quietchat", + Body: "real user message", + }), + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe("legacy-daily-session"); + }); + + it("does not let heartbeat keep a legacy idle session fresh without lastInteractionAt", async () => { + const now = Date.now(); + const staleTime = now - 10 * 60 * 1000; + const sessionFile = path.join(tempDir, "legacy-idle-session.jsonl"); + await fs.writeFile( + sessionFile, + `${JSON.stringify({ + type: "session", + version: 3, + id: "legacy-idle-session", + timestamp: new Date(staleTime).toISOString(), + cwd: tempDir, + })}\n`, + "utf8", + ); + await saveExistingSession("legacy-idle-session", now, { + sessionFile, + }); + + const cfg = createBaseConfig(); + const heartbeatResult = await initSessionState({ + ctx: createBaseCtx({ + Provider: "heartbeat", + Body: "HEARTBEAT_OK", + }), + cfg, + commandAuthorized: true, + }); + + expect(heartbeatResult.isNewSession).toBe(false); + expect(heartbeatResult.sessionId).toBe("legacy-idle-session"); + + const persistedAfterHeartbeat = loadSessionStore(storePath); + expect(persistedAfterHeartbeat["main:user123"]?.lastInteractionAt).toBeUndefined(); + + const userResult = await initSessionState({ + ctx: createBaseCtx({ + Provider: "quietchat", + Body: "real user message", + }), + cfg, + commandAuthorized: true, + }); + + expect(userResult.isNewSession).toBe(true); + expect(userResult.sessionId).not.toBe("legacy-idle-session"); + }); + it("should handle cron-event provider same as heartbeat (no reset)", async () => { // Setup: Create a stale session const now = Date.now(); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index d703eb32243..807c8a13936 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -7,6 +7,7 @@ import { resetRegisteredAgentHarnessSessions } from "../../agents/harness/regist import { retireSessionMcpRuntime } from "../../agents/pi-bundle-mcp-tools.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import { resolveGroupSessionKey } from "../../config/sessions/group.js"; +import { resolveSessionLifecycleTimestamps } from "../../config/sessions/lifecycle.js"; import { canonicalizeMainSessionAlias } from "../../config/sessions/main-session.js"; import { deriveSessionMetaPatch } from "../../config/sessions/metadata.js"; import { resolveSessionTranscriptPath, resolveStorePath } from "../../config/sessions/paths.js"; @@ -433,12 +434,22 @@ export async function initSessionState(params: { Boolean(entry?.sessionId) && typeof entry?.updatedAt === "number" && Number.isFinite(entry.updatedAt); - // Forcing freshEntry=true prevents accidental data loss on automated system events. const skipImplicitExpiry = hasProviderOwnedSession(entry) && resetPolicy.configured !== true; + const lifecycleTimestamps = resolveSessionLifecycleTimestamps({ + entry, + agentId, + storePath, + }); const entryFreshness = entry - ? isSystemEvent || skipImplicitExpiry + ? skipImplicitExpiry ? ({ fresh: true } satisfies SessionFreshness) - : evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }) + : evaluateSessionFreshness({ + updatedAt: entry.updatedAt, + sessionStartedAt: lifecycleTimestamps.sessionStartedAt, + lastInteractionAt: lifecycleTimestamps.lastInteractionAt, + now, + policy: resetPolicy, + }) : undefined; const softResetAllowed = softReset.matched && @@ -456,7 +467,9 @@ export async function initSessionState(params: { }) ?? "", ); const freshEntry = - (entryFreshness?.fresh ?? false) || (softResetAllowed && canReuseExistingEntry); + (isSystemEvent && canReuseExistingEntry) || + (entryFreshness?.fresh ?? false) || + (softResetAllowed && canReuseExistingEntry); // Capture the current session entry before any reset so its transcript can be // archived afterward. We need to do this for both explicit resets (/new, /reset) // and for scheduled/daily resets where the session has become stale (!freshEntry). @@ -601,6 +614,10 @@ export async function initSessionState(params: { ...baseEntry, sessionId, updatedAt: Date.now(), + sessionStartedAt: isNewSession + ? now + : (baseEntry?.sessionStartedAt ?? lifecycleTimestamps.sessionStartedAt), + lastInteractionAt: isSystemEvent ? baseEntry?.lastInteractionAt : now, systemSent, abortedLastRun, // Persist previously stored thinking/verbose levels when present. diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 6586391fca0..2cc5c170edf 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -4,6 +4,7 @@ export * from "./sessions/artifacts.js"; export * from "./sessions/metadata.js"; export * from "./sessions/main-session.js"; export * from "./sessions/main-session.runtime.js"; +export * from "./sessions/lifecycle.js"; export * from "./sessions/paths.js"; export * from "./sessions/reset.js"; export * from "./sessions/session-key.js"; diff --git a/src/config/sessions/lifecycle.ts b/src/config/sessions/lifecycle.ts new file mode 100644 index 00000000000..428663f92cf --- /dev/null +++ b/src/config/sessions/lifecycle.ts @@ -0,0 +1,114 @@ +import fs from "node:fs"; +import { + resolveSessionFilePath, + resolveSessionFilePathOptions, + type SessionFilePathOptions, +} from "./paths.js"; +import type { SessionEntry } from "./types.js"; + +type SessionLifecycleEntry = Pick< + SessionEntry, + "sessionId" | "sessionFile" | "sessionStartedAt" | "lastInteractionAt" | "updatedAt" +>; + +function resolveTimestamp(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function parseTimestampMs(value: unknown): number | undefined { + if (typeof value === "number") { + return resolveTimestamp(value); + } + if (typeof value !== "string" || !value.trim()) { + return undefined; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + +function readFirstLine(filePath: string): string | undefined { + try { + const fd = fs.openSync(filePath, "r"); + try { + const buffer = Buffer.alloc(8192); + const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0); + if (bytesRead <= 0) { + return undefined; + } + const chunk = buffer.subarray(0, bytesRead).toString("utf8"); + const newline = chunk.indexOf("\n"); + return newline >= 0 ? chunk.slice(0, newline) : chunk; + } finally { + fs.closeSync(fd); + } + } catch { + return undefined; + } +} + +export function readSessionHeaderStartedAtMs(params: { + entry: SessionLifecycleEntry | undefined; + agentId?: string; + storePath?: string; + pathOptions?: SessionFilePathOptions; +}): number | undefined { + const sessionId = params.entry?.sessionId?.trim(); + if (!sessionId) { + return undefined; + } + const pathOptions = + params.pathOptions ?? + resolveSessionFilePathOptions({ + agentId: params.agentId, + storePath: params.storePath, + }); + let sessionFile: string; + try { + sessionFile = resolveSessionFilePath(sessionId, params.entry, pathOptions); + } catch { + return undefined; + } + const firstLine = readFirstLine(sessionFile); + if (!firstLine) { + return undefined; + } + try { + const header = JSON.parse(firstLine) as { + type?: unknown; + id?: unknown; + timestamp?: unknown; + }; + if (header.type !== "session") { + return undefined; + } + if (typeof header.id === "string" && header.id.trim() && header.id !== sessionId) { + return undefined; + } + return parseTimestampMs(header.timestamp); + } catch { + return undefined; + } +} + +export function resolveSessionLifecycleTimestamps(params: { + entry: SessionLifecycleEntry | undefined; + agentId?: string; + storePath?: string; + pathOptions?: SessionFilePathOptions; +}): { sessionStartedAt?: number; lastInteractionAt?: number } { + const entry = params.entry; + if (!entry) { + return {}; + } + return { + sessionStartedAt: + resolveTimestamp(entry.sessionStartedAt) ?? + readSessionHeaderStartedAtMs({ + entry, + agentId: params.agentId, + storePath: params.storePath, + pathOptions: params.pathOptions, + }), + lastInteractionAt: resolveTimestamp(entry.lastInteractionAt), + }; +} diff --git a/src/config/sessions/reset-policy.ts b/src/config/sessions/reset-policy.ts index 66df22d5512..0191b90851b 100644 --- a/src/config/sessions/reset-policy.ts +++ b/src/config/sessions/reset-policy.ts @@ -71,18 +71,22 @@ export function resolveSessionResetPolicy(params: { export function evaluateSessionFreshness(params: { updatedAt: number; + sessionStartedAt?: number; + lastInteractionAt?: number; now: number; policy: SessionResetPolicy; }): SessionFreshness { + const sessionStartedAt = resolveTimestamp(params.sessionStartedAt) ?? params.updatedAt; + const lastInteractionAt = resolveTimestamp(params.lastInteractionAt) ?? sessionStartedAt; const dailyResetAt = params.policy.mode === "daily" ? resolveDailyResetAtMs(params.now, params.policy.atHour) : undefined; const idleExpiresAt = params.policy.idleMinutes != null && params.policy.idleMinutes > 0 - ? params.updatedAt + params.policy.idleMinutes * 60_000 + ? lastInteractionAt + params.policy.idleMinutes * 60_000 : undefined; - const staleDaily = dailyResetAt != null && params.updatedAt < dailyResetAt; + const staleDaily = dailyResetAt != null && sessionStartedAt < dailyResetAt; const staleIdle = idleExpiresAt != null && params.now > idleExpiresAt; return { fresh: !(staleDaily || staleIdle), @@ -91,6 +95,10 @@ export function evaluateSessionFreshness(params: { }; } +function resolveTimestamp(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + function normalizeResetAtHour(value: number | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_RESET_AT_HOUR; diff --git a/src/config/sessions/session-file.ts b/src/config/sessions/session-file.ts index a37dcb08847..5eea82e7edc 100644 --- a/src/config/sessions/session-file.ts +++ b/src/config/sessions/session-file.ts @@ -16,8 +16,9 @@ export async function resolveAndPersistSessionFile(params: { maintenanceConfig?: ResolvedSessionMaintenanceConfig; }): Promise<{ sessionFile: string; sessionEntry: SessionEntry }> { const { sessionId, sessionKey, sessionStore, storePath } = params; + const now = Date.now(); const baseEntry = params.sessionEntry ?? - sessionStore[sessionKey] ?? { sessionId, updatedAt: Date.now() }; + sessionStore[sessionKey] ?? { sessionId, updatedAt: now, sessionStartedAt: now }; const shouldReusePersistedSessionFile = baseEntry.sessionId === sessionId; const fallbackSessionFile = params.fallbackSessionFile?.trim(); const entryForResolve = !shouldReusePersistedSessionFile @@ -34,7 +35,8 @@ export async function resolveAndPersistSessionFile(params: { const persistedEntry: SessionEntry = { ...baseEntry, sessionId, - updatedAt: Date.now(), + updatedAt: now, + sessionStartedAt: baseEntry.sessionId === sessionId ? (baseEntry.sessionStartedAt ?? now) : now, sessionFile, }; if (baseEntry.sessionId !== sessionId || baseEntry.sessionFile !== sessionFile) { diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 07d43fc9c6d..e33ecddcdb6 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -7,6 +7,7 @@ import * as jsonFiles from "../../infra/json-files.js"; import { createSuiteTempRootTracker, withTempDirSync } from "../../test-helpers/temp-dir.js"; import type { OpenClawConfig } from "../config.js"; import type { SessionConfig } from "../types.base.js"; +import { resolveSessionLifecycleTimestamps } from "./lifecycle.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions, @@ -148,6 +149,94 @@ describe("resolveSessionResetPolicy", () => { idleExpiresAt: undefined, }); }); + + it("uses sessionStartedAt, not updatedAt, for daily reset freshness", () => { + const now = new Date(2026, 3, 25, 12, 0, 0, 0).getTime(); + const freshness = evaluateSessionFreshness({ + updatedAt: now, + sessionStartedAt: now - 25 * 60 * 60_000, + now, + policy: { + mode: "daily", + atHour: 4, + }, + }); + + expect(freshness.fresh).toBe(false); + }); + + it("uses lastInteractionAt, not updatedAt, for idle reset freshness", () => { + const now = 60 * 60_000; + const freshness = evaluateSessionFreshness({ + updatedAt: now, + lastInteractionAt: 0, + now, + policy: { + mode: "idle", + atHour: 4, + idleMinutes: 5, + }, + }); + + expect(freshness).toMatchObject({ + fresh: false, + idleExpiresAt: 5 * 60_000, + }); + }); + + it("falls back to sessionStartedAt, not updatedAt, for legacy idle freshness", () => { + const now = 60 * 60_000; + const freshness = evaluateSessionFreshness({ + updatedAt: now, + sessionStartedAt: 0, + now, + policy: { + mode: "idle", + atHour: 4, + idleMinutes: 5, + }, + }); + + expect(freshness).toMatchObject({ + fresh: false, + idleExpiresAt: 5 * 60_000, + }); + }); +}); + +describe("session lifecycle timestamps", () => { + it("falls back to the JSONL session header for legacy session start time", async () => { + const dir = await fsPromises.mkdtemp("/tmp/openclaw-lifecycle-test-"); + try { + const storePath = path.join(dir, "sessions.json"); + const sessionFile = path.join(dir, "legacy-session.jsonl"); + const headerTimestamp = "2026-04-20T04:30:00.000Z"; + await fsPromises.writeFile( + sessionFile, + `${JSON.stringify({ + type: "session", + version: 3, + id: "legacy-session", + timestamp: headerTimestamp, + cwd: dir, + })}\n`, + "utf8", + ); + + const timestamps = resolveSessionLifecycleTimestamps({ + storePath, + entry: { + sessionId: "legacy-session", + sessionFile, + updatedAt: Date.parse("2026-04-25T08:00:00.000Z"), + }, + }); + + expect(timestamps.sessionStartedAt).toBe(Date.parse(headerTimestamp)); + } finally { + await fsPromises.rm(dir, { recursive: true, force: true }); + } + }); }); describe("session store lock (Promise chain mutex)", () => { diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 9822753b6a5..17226fbeef6 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -147,6 +147,10 @@ export type SessionEntry = { subagentControlScope?: "children" | "none"; systemSent?: boolean; abortedLastRun?: boolean; + /** Timestamp (ms) when the current sessionId first became active. */ + sessionStartedAt?: number; + /** Timestamp (ms) of the last user/channel interaction that should extend idle lifetime. */ + lastInteractionAt?: number; /** Stable first-run start time for subagent sessions, persisted after completion. */ startedAt?: number; /** Latest completed run end time for subagent sessions, persisted after completion. */ @@ -383,9 +387,22 @@ export function mergeSessionEntryWithPolicy( const sessionId = patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID(); const updatedAt = resolveMergedUpdatedAt(existing, patch, options); if (!existing) { - return normalizeSessionRuntimeModelFields({ ...patch, sessionId, updatedAt }); + return normalizeSessionRuntimeModelFields({ + ...patch, + sessionId, + updatedAt, + sessionStartedAt: patch.sessionStartedAt ?? updatedAt, + }); } - const next = { ...existing, ...patch, sessionId, updatedAt }; + const next = { + ...existing, + ...patch, + sessionId, + updatedAt, + sessionStartedAt: + patch.sessionStartedAt ?? + (existing.sessionId === sessionId ? existing.sessionStartedAt : updatedAt), + }; // Guard against stale provider carry-over when callers patch runtime model // without also patching runtime provider. diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index 8bfc20636c9..67de4a2601a 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -7,6 +7,8 @@ vi.mock("../../config/sessions/store-load.js", () => ({ vi.mock("../../config/sessions/paths.js", () => ({ resolveStorePath: vi.fn().mockReturnValue("/tmp/test-store.json"), + resolveSessionFilePathOptions: vi.fn().mockReturnValue({ sessionsDir: "/tmp" }), + resolveSessionFilePath: vi.fn((sessionId: string) => `/tmp/${sessionId}.jsonl`), })); vi.mock("../../config/sessions/reset-policy.js", () => ({ @@ -109,16 +111,19 @@ describe("resolveCronSession", () => { // New tests for session reuse behavior (#18027) describe("session reuse for webhooks/cron", () => { it("reuses existing sessionId when session is fresh", () => { + const lastInteractionAt = NOW_MS - 30 * 60_000; const result = resolveWithStoredEntry({ entry: { sessionId: "existing-session-id-123", updatedAt: NOW_MS - 1000, + lastInteractionAt, systemSent: true, }, fresh: true, }); expect(result.sessionEntry.sessionId).toBe("existing-session-id-123"); + expect(result.sessionEntry.lastInteractionAt).toBe(lastInteractionAt); expect(result.isNewSession).toBe(false); expect(result.previousSessionId).toBeUndefined(); expect(result.systemSent).toBe(true); diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index 7772a3791a3..fc59ef9199b 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js"; +import { resolveSessionLifecycleTimestamps } from "../../config/sessions/lifecycle.js"; import { resolveStorePath } from "../../config/sessions/paths.js"; import { evaluateSessionFreshness, @@ -127,6 +128,11 @@ export function resolveCronSession(params: { }); const freshness = evaluateSessionFreshness({ updatedAt: entry.updatedAt, + ...resolveSessionLifecycleTimestamps({ + entry, + agentId: params.agentId, + storePath, + }), now: params.nowMs, policy: resetPolicy, }); @@ -167,6 +173,15 @@ export function resolveCronSession(params: { // Always update these core fields sessionId, updatedAt: params.nowMs, + sessionStartedAt: isNewSession + ? params.nowMs + : (baseEntry?.sessionStartedAt ?? + resolveSessionLifecycleTimestamps({ + entry, + agentId: params.agentId, + storePath, + }).sessionStartedAt), + lastInteractionAt: isNewSession ? params.nowMs : baseEntry?.lastInteractionAt, systemSent, }; return { storePath, store, sessionEntry, systemSent, isNewSession, previousSessionId }; diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index a6fa587ca4c..8c3e667efca 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -1075,6 +1075,129 @@ describe("gateway agent handler", () => { expect(call?.sessionKey).toBe("agent:main:main"); }); + it("rolls stale gateway agent sessions even when updatedAt was recently touched", async () => { + const now = Date.parse("2026-04-25T12:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + try { + mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:main:main"); + mockMainSessionEntry( + { + sessionId: "stale-session-id", + updatedAt: now, + sessionStartedAt: now - 25 * 60 * 60_000, + lastInteractionAt: now - 25 * 60 * 60_000, + }, + { + session: { + reset: { + mode: "daily", + atHour: 4, + }, + }, + }, + ); + const loaded = mocks.loadSessionEntry(); + let capturedEntry: Record | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + [loaded.canonicalKey]: structuredClone(loaded.entry), + }; + const result = await updater(store); + capturedEntry = result as Record; + return result; + }); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + await invokeAgent( + { + message: "daily rollover", + agentId: "main", + sessionKey: "agent:main:main", + idempotencyKey: "daily-rollover-agent-session", + }, + { reqId: "daily-rollover-agent-session" }, + ); + + await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled()); + const call = mocks.agentCommand.mock.calls.at(-1)?.[0] as { + sessionId?: string; + sessionKey?: string; + }; + expect(call?.sessionKey).toBe("agent:main:main"); + expect(call?.sessionId).not.toBe("stale-session-id"); + expect(capturedEntry?.sessionStartedAt).toBe(now); + expect(capturedEntry?.lastInteractionAt).toBe(now); + } finally { + vi.useRealTimers(); + } + }); + + it("does not let explicit sessionId bypass stale gateway session freshness", async () => { + const now = Date.parse("2026-04-25T12:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + try { + mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:main:main"); + mockMainSessionEntry( + { + sessionId: "stale-session-id", + updatedAt: now, + sessionStartedAt: now - 25 * 60 * 60_000, + lastInteractionAt: now - 25 * 60 * 60_000, + }, + { + session: { + reset: { + mode: "daily", + atHour: 4, + }, + }, + }, + ); + const loaded = mocks.loadSessionEntry(); + let capturedEntry: Record | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + [loaded.canonicalKey]: structuredClone(loaded.entry), + }; + const result = await updater(store); + capturedEntry = result as Record; + return result; + }); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + await invokeAgent( + { + message: "daily rollover", + agentId: "main", + sessionKey: "agent:main:main", + sessionId: "stale-session-id", + idempotencyKey: "daily-rollover-agent-session-id", + }, + { reqId: "daily-rollover-agent-session-id" }, + ); + + await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled()); + const call = mocks.agentCommand.mock.calls.at(-1)?.[0] as { + sessionId?: string; + sessionKey?: string; + }; + expect(call?.sessionKey).toBe("agent:main:main"); + expect(call?.sessionId).not.toBe("stale-session-id"); + expect(capturedEntry?.sessionStartedAt).toBe(now); + expect(capturedEntry?.lastInteractionAt).toBe(now); + } finally { + vi.useRealTimers(); + } + }); + it("does not forward a non-main agent id with canonical global session keys", async () => { mocks.listAgentIds.mockReturnValue(["main", "ops"]); mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:ops:main"); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index c61f2fda4ab..a56819cc5a6 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -21,10 +21,15 @@ import { import { agentCommandFromIngress } from "../../commands/agent.js"; import { loadConfig } from "../../config/config.js"; import { + evaluateSessionFreshness, mergeSessionEntry, + resolveChannelResetConfig, resolveAgentIdFromSessionKey, resolveExplicitAgentSessionKey, resolveAgentMainSessionKey, + resolveSessionLifecycleTimestamps, + resolveSessionResetPolicy, + resolveSessionResetType, type SessionEntry, updateSessionStore, } from "../../config/sessions.js"; @@ -638,9 +643,43 @@ export const agentHandlers: GatewayRequestHandlers = { if (requestedSessionKey) { const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(requestedSessionKey); cfgForAgent = cfg; - isNewSession = !entry; const now = Date.now(); - const sessionId = entry?.sessionId ?? randomUUID(); + const resetPolicy = resolveSessionResetPolicy({ + sessionCfg: cfg.session, + resetType: resolveSessionResetType({ sessionKey: canonicalKey }), + resetOverride: resolveChannelResetConfig({ + sessionCfg: cfg.session, + channel: entry?.lastChannel ?? entry?.channel ?? request.channel, + }), + }); + const freshness = entry + ? evaluateSessionFreshness({ + updatedAt: entry.updatedAt, + ...resolveSessionLifecycleTimestamps({ + entry, + storePath, + agentId: resolveAgentIdFromSessionKey(canonicalKey), + }), + now, + policy: resetPolicy, + }) + : undefined; + const canReuseSession = Boolean(entry?.sessionId) && (freshness?.fresh ?? false); + const usableRequestedSessionId = + requestedSessionId && (!entry?.sessionId || canReuseSession) + ? requestedSessionId + : undefined; + const sessionId = usableRequestedSessionId + ? usableRequestedSessionId + : ((canReuseSession ? entry?.sessionId : undefined) ?? randomUUID()); + isNewSession = + !entry || + (!canReuseSession && !usableRequestedSessionId) || + Boolean(usableRequestedSessionId && entry?.sessionId !== usableRequestedSessionId); + const touchInteraction = + request.bootstrapContextRunKind !== "cron" && + request.bootstrapContextRunKind !== "heartbeat" && + !request.internalEvents?.length; const labelValue = normalizeOptionalString(request.label) || entry?.label; const sessionAgent = resolveAgentIdFromSessionKey(canonicalKey); spawnedByValue = canonicalizeSpawnedByForAgent(cfg, sessionAgent, entry?.spawnedBy); @@ -686,6 +725,15 @@ export const agentHandlers: GatewayRequestHandlers = { const nextEntryPatch: SessionEntry = { sessionId, updatedAt: now, + sessionStartedAt: isNewSession + ? now + : (entry?.sessionStartedAt ?? + resolveSessionLifecycleTimestamps({ + entry, + storePath, + agentId: resolveAgentIdFromSessionKey(canonicalKey), + }).sessionStartedAt), + lastInteractionAt: touchInteraction ? now : entry?.lastInteractionAt, thinkingLevel: entry?.thinkingLevel, fastMode: entry?.fastMode, verboseLevel: entry?.verboseLevel,