openclaw - 💡(How to fix) Fix Gateway: cold session under stable key doesn't replay prior transcript when prior runtime was reaped

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

When an inbound channel message (Telegram, Signal, etc.) arrives on a stable session key (e.g. agent:main:direct:<peer> with dmScope: per-peer), but the prior session entry has gone stale (idle-TTL elapsed since the last message), the gateway:

  1. Mints a brand-new sessionId (crypto.randomUUID())
  2. Builds the new session entry from an undefined baseEntry — so no prior state is carried over (not even cliSessionIds, which would have enabled CLI-backend --resume)
  3. Archives the prior .jsonl transcript rather than replaying it into the new runtime

The new runtime therefore starts with zero conversational memory. From the user's POV the assistant has amnesia mid-chat: a follow-up Telegram message like "yes" lands on a fresh runtime that has no idea what was being agreed to.

The session key is stable across the rollover, but nothing about the prior transcript is.

Root Cause

The same content sent using Telegram's native reply-to worked, because Telegram inlines the prior message body and the body itself carried the context. That is the workaround, not a fix.

Fix Action

Fix / Workaround

The same content sent using Telegram's native reply-to worked, because Telegram inlines the prior message body and the body itself carried the context. That is the workaround, not a fix.

Code Example

const freshEntry = entry
  ? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh
  : false;
...
if (!isNewSession && freshEntry) {
  sessionId = entry.sessionId;
  ...
} else {
  sessionId = crypto.randomUUID();
  isNewSession = true;
  ...
  // only carries persisted-prefs over when resetTriggered (explicit /new, /reset)
  // — does NOT carry anything over for an implicit idle-TTL rollover.
}

---

const baseEntry = !isNewSession && freshEntry ? entry : undefined;
...
sessionEntry = { ...baseEntry, sessionId, updatedAt: Date.now(), ... };

---

if (previousSessionEntry?.sessionId) {
  archiveSessionTranscripts({ sessionId: previousSessionEntry.sessionId, ..., reason: "reset" });
}
RAW_BUFFERClick to expand / collapse

Summary

When an inbound channel message (Telegram, Signal, etc.) arrives on a stable session key (e.g. agent:main:direct:<peer> with dmScope: per-peer), but the prior session entry has gone stale (idle-TTL elapsed since the last message), the gateway:

  1. Mints a brand-new sessionId (crypto.randomUUID())
  2. Builds the new session entry from an undefined baseEntry — so no prior state is carried over (not even cliSessionIds, which would have enabled CLI-backend --resume)
  3. Archives the prior .jsonl transcript rather than replaying it into the new runtime

The new runtime therefore starts with zero conversational memory. From the user's POV the assistant has amnesia mid-chat: a follow-up Telegram message like "yes" lands on a fresh runtime that has no idea what was being agreed to.

The session key is stable across the rollover, but nothing about the prior transcript is.

Reproduction

  1. Use Telegram with dmScope: per-peer so the session key looks like agent:<agent>:direct:<peer>.
  2. Have a multi-turn conversation. Stop messaging.
  3. Wait longer than the effective idle-reset window (default DEFAULT_IDLE_MINUTES = 60; longer if session.reset.idleMinutes or session.idleMinutes is set).
  4. Send a new Telegram message — without using Telegram's native reply-to (which would otherwise smuggle context in via reply_to).
  5. Observe: the model responds as if it has never spoken to you before. openclaw status will show a new sessionId (different UUID) under the same session key, with startedAt == updatedAt and the prior *.trajectory.jsonl still present on disk but archived/orphaned.

Expected

The new turn should continue the prior conversation — either by reusing the prior sessionId (and reopening its transcript) or by replaying the prior transcript into the new runtime.

Actual

A fresh runtime is spawned with no prior context. The prior transcript is archived to disk but never re-fed.

Concrete evidence from one host

  • Version: OpenClaw 2026.5.7 (eeef486)
  • Session key: agent:main:direct:michael
  • Prior session id: 92df2ea3-0f56-4b3b-ab35-f2c4c15a7ac8, last activity 2026-05-11T21:45 UTC
  • Next Telegram message: 2026-05-12T05:18 UTC (~7h 30m gap)
  • New session id minted: f936d98d-cd55-4f4d-adeb-487740dedc41, startedAt == updatedAt
  • New runtime token stats at first reply: Tokens: 23 in / 3.9k out · Cache: 99% hit · 1.1m cached, 8.1k new — the cached portion is system prompt + skills only; none of the prior transcript was replayed.

The same content sent using Telegram's native reply-to worked, because Telegram inlines the prior message body and the body itself carried the context. That is the workaround, not a fix.

Where this happens in the code

Repo HEAD at time of investigation: 3caab9260.

1. Freshness gate + new-sessionId mintsrc/auto-reply/reply/session.ts L354-L395

const freshEntry = entry
  ? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh
  : false;
...
if (!isNewSession && freshEntry) {
  sessionId = entry.sessionId;
  ...
} else {
  sessionId = crypto.randomUUID();
  isNewSession = true;
  ...
  // only carries persisted-prefs over when resetTriggered (explicit /new, /reset)
  // — does NOT carry anything over for an implicit idle-TTL rollover.
}

2. New sessionEntry is built from baseEntry = undefined on stalesrc/auto-reply/reply/session.ts L397 and L430-L463

const baseEntry = !isNewSession && freshEntry ? entry : undefined;
...
sessionEntry = { ...baseEntry, sessionId, updatedAt: Date.now(), ... };

Consequence: entry.cliSessionIds (the per-provider CLI session map used by --resume) is not carried over on idle-stale rollover, so even CLI backends that could resume via cli-runner get a fresh CLI session too.

3. Prior transcript is archived, not replayedsrc/auto-reply/reply/session.ts L566-L575

if (previousSessionEntry?.sessionId) {
  archiveSessionTranscripts({ sessionId: previousSessionEntry.sessionId, ..., reason: "reset" });
}

archiveSessionTranscripts (src/gateway/session-utils.fs.ts L188-L228) renames the file on disk; nothing reads it back into the new runtime.

4. Freshness policysrc/config/sessions/reset.ts L139-L159 — default idleMinutes = 60 if no explicit session.reset config.

5. CLI-backend resume path that already exists but is unused for idle rolloverssrc/agents/cli-runner.ts L202-L231 consumes a cliSessionId to pass --resume {sessionId} to backends like Claude Code CLI or Codex. Source is getCliSessionId(entry, provider) (src/agents/cli-session.ts#L4-L23), but as noted in (2), the cliSessionIds map is lost on the rollover so this never fires for stale-rollover turns.

Is this a regression or never-implemented?

This appears to be never implemented, not a regression. git log -- src/auto-reply/reply/session.ts back ~12 months shows the freshness/archive flow has been the design (#14949, #35493, etc.), and the only "resume" feature added is ACP-side resumeSessionId for the Anthropic control-plane (commit aca216bfc, #41847), which is not invoked from the channel inbound path.

Suggested fix direction (one or two sentences)

When entry exists but !freshEntry, prefer reusing entry.sessionId (keeping sessionFile and cliSessionIds intact) and let downstream compaction//new flows decide whether to truncate — i.e. treat idle-TTL as a soft "rotate runtime, not history" boundary rather than a hard reset. Failing that, propagate previousSessionEntry.cliSessionIds and a resumeFromSessionFile hint onto the new entry so CLI backends and embedded runners can rehydrate the prior transcript.

Either approach should be gated by session.reset.mode so explicit "hard reset" configurations (mode: "daily", /new, /reset, resetTriggered) keep today's behavior.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING