Skip to content

fix: gateway agent method bypasses session daily/idle reset#47839

Closed
avaclaw1 wants to merge 1 commit into
openclaw:mainfrom
avaclaw1:fix/gateway-agent-session-freshness
Closed

fix: gateway agent method bypasses session daily/idle reset#47839
avaclaw1 wants to merge 1 commit into
openclaw:mainfrom
avaclaw1:fix/gateway-agent-session-freshness

Conversation

@avaclaw1

Copy link
Copy Markdown

Summary

The gateway agent RPC method handler in src/gateway/server-methods/agent.ts pre-resolves the sessionId from the session store entry without evaluating session freshness. This sessionId is then passed explicitly to resolveSession() in src/commands/agent/session.ts, which skips its own freshness check when opts.sessionId is provided.

Impact: Sessions that receive periodic callGateway({ method: "agent" }) calls — most commonly via cron announce delivery — will never reset through the daily or idle timeout mechanism. The cron delivery also refreshes updatedAt on each invocation, preventing any subsequent freshness check from detecting staleness.

Root Cause

In src/gateway/server-methods/agent.ts (line 354):

const sessionId = entry?.sessionId ?? randomUUID();

This unconditionally reuses the stored session ID. Later, resolvedSessionId = sessionId passes it to agentCommandFromIngress, where resolveSession() has:

const sessionId =
  opts.sessionId?.trim() ||           // takes priority, skips freshness
  (fresh ? sessionEntry?.sessionId : undefined) ||
  crypto.randomUUID();

Since opts.sessionId is already set, the fresh evaluation is bypassed entirely.

Fix

Evaluate session freshness using the existing reset policy pipeline (resolveSessionResetType -> resolveChannelResetConfig -> resolveSessionResetPolicy -> evaluateSessionFreshness) before deciding whether to reuse the existing sessionId or mint a new UUID.

Reproduction

  1. Configure a cron job with sessionTarget: "isolated" delivering via announce to a Telegram group
  2. Default session config: reset.mode: "daily", atHour: 4
  3. Cron fires at 9 AM daily (after the 4 AM reset window)
  4. Observe the group session retains the same sessionId indefinitely -- daily reset never triggers
  5. Session transcript grows unbounded with compactions accumulating

Testing

  • AI-assisted: the bug was identified by tracing the source code path from cron delivery through the gateway agent handler to resolveSession()
  • Verified the freshness math: resolveDailyResetAtMs correctly computes 4 AM local time, and evaluateSessionFreshness correctly returns fresh: false for sessions with overnight gaps crossing the reset hour
  • The fix reuses the exact same freshness evaluation pipeline used by resolveSession() and dispatchInboundMessage()

AI-assisted (Claude Code)

The gateway agent RPC handler pre-resolved the sessionId from the store
entry without checking session freshness (daily/idle reset policy). This
sessionId was then passed to resolveSession() which skips its own
freshness check when opts.sessionId is explicitly provided.

As a result, sessions targeted by cron announce delivery or any
callGateway({ method: "agent" }) call with a sessionKey would never
reset via the daily or idle reset mechanism. The cron delivery also
refreshes updatedAt on each run, preventing subsequent freshness checks
from detecting staleness.

The fix evaluates session freshness using the same reset policy pipeline
before deciding whether to reuse the existing sessionId or mint a new
one via randomUUID().
@openclaw-barnacle openclaw-barnacle Bot added gateway Gateway runtime size: XS labels Mar 16, 2026
@greptile-apps

greptile-apps Bot commented Mar 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a real session-reset bug in the gateway agent RPC handler: by pre-resolving and unconditionally forwarding the stored sessionId to resolveSession(), the daily/idle freshness check inside resolveSession() was always bypassed, causing sessions that receive periodic cron-announce calls to grow indefinitely without ever resetting.

The fix correctly mirrors the freshness-evaluation pipeline already used by resolveSession() and resolveCronSession(). However, the rollover path is incomplete in two ways compared to those reference implementations:

  • systemSent not cleared on rollover — when fresh is false, nextEntryPatch still carries systemSent: entry?.systemSent. If the old session had systemSent: true, the new session (with an empty conversation history under the new UUID) will skip system prompt injection, leaving the agent without its persona and instructions for its first turn. resolveCronSession() explicitly sets systemSent = false for new sessions.
  • Bootstrap cache not evicted — neither the new code in agent.ts nor the downstream resolveSession() call (which sees fresh = true and isNewSession = false by the time it runs) will call clearBootstrapSnapshotOnSessionRollover. clearBootstrapSnapshotOnSessionRollover should be imported from ../../agents/bootstrap-cache.js and called when !fresh to match the behaviour of both reference implementations.

Confidence Score: 3/5

  • The core bug is correctly fixed, but two incomplete rollover cleanups could cause the first turn of a reset session to operate without its system prompt and with stale workspace bootstrap files.
  • The fix correctly addresses the root cause (session ID pre-resolution bypassing the freshness check) and mirrors the evaluation pipeline used elsewhere. The confidence deduction is for two missing rollover side-effects — systemSent not being cleared and the bootstrap cache not being evicted — both of which are handled in the analogous resolveCronSession and resolveSession implementations.
  • src/gateway/server-methods/agent.ts — specifically the nextEntryPatch construction around line 403 (systemSent field) and the missing clearBootstrapSnapshotOnSessionRollover call after the freshness block.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/gateway/server-methods/agent.ts
Line: 363-380

Comment:
**`systemSent` not reset on session rollover**

When `fresh` is `false` and a new `sessionId` is minted, `systemSent` is still carried over from the stale entry via `nextEntryPatch` (line ~406: `systemSent: entry?.systemSent`). If the old session had `systemSent: true`, the new session (with an empty conversation history identified by the new UUID) will skip system prompt injection — leaving the agent without its persona and instructions for the first turn.

Both reference implementations handle this explicitly:

`resolveCronSession()` (src/cron/isolated-agent/session.ts:50-54):
```typescript
} else {
  // Session expired, create new
  sessionId = crypto.randomUUID();
  isNewSession = true;
  systemSent = false;   // ← reset here
}
```

`resolveSession()` (src/commands/agent/session.ts:146):
```typescript
const isNewSession = !fresh && !opts.sessionId;
```
…and `persistedThinking`/`persistedVerbose` are also only forwarded when `fresh === true`.

The fix should reset `systemSent` (and, arguably, `skillsSnapshot`) when the freshness check decides to rotate the session:

```typescript
sessionId = fresh ? entry.sessionId : randomUUID();
const isRolledOver = !fresh;
```

Then in `nextEntryPatch`:
```typescript
systemSent: isRolledOver ? undefined : entry?.systemSent,
skillsSnapshot: isRolledOver ? undefined : entry?.skillsSnapshot,
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/gateway/server-methods/agent.ts
Line: 376-380

Comment:
**Bootstrap cache not evicted on rollover**

Both `resolveCronSession()` and `resolveSession()` call `clearBootstrapSnapshotOnSessionRollover` whenever a session transitions to a new ID, ensuring the workspace bootstrap file cache is evicted. This code path skips that step entirely.

By the time `agentCommandFromIngress` runs, the store already has `updatedAt: now` with the new UUID, so `resolveSession()` will see `fresh = true` and `isNewSession = false` — meaning its own `clearBootstrapSnapshotOnSessionRollover` call is also a no-op (guarded by the `isNewSession` flag). The eviction is therefore never performed.

The fix should import `clearBootstrapSnapshotOnSessionRollover` from `../../agents/bootstrap-cache.js` and call it right after the rollover is detected (when `!fresh`), passing `canonicalKey` as the session key and the old `entry.sessionId` as the previous session ID.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 7db18a8

Comment on lines +363 to +380
let sessionId: string;
if (entry?.sessionId && entry.updatedAt != null) {
const sessionCfg = cfg.session;
const resetType = resolveSessionResetType({ sessionKey: canonicalKey });
const resetOverride = resolveChannelResetConfig({
sessionCfg,
channel: entry.lastChannel ?? entry.channel,
});
const policy = resolveSessionResetPolicy({ sessionCfg, resetType, resetOverride });
const { fresh } = evaluateSessionFreshness({
updatedAt: entry.updatedAt,
now,
policy,
});
sessionId = fresh ? entry.sessionId : randomUUID();
} else {
sessionId = entry?.sessionId ?? randomUUID();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

systemSent not reset on session rollover

When fresh is false and a new sessionId is minted, systemSent is still carried over from the stale entry via nextEntryPatch (line ~406: systemSent: entry?.systemSent). If the old session had systemSent: true, the new session (with an empty conversation history identified by the new UUID) will skip system prompt injection — leaving the agent without its persona and instructions for the first turn.

Both reference implementations handle this explicitly:

resolveCronSession() (src/cron/isolated-agent/session.ts:50-54):

} else {
  // Session expired, create new
  sessionId = crypto.randomUUID();
  isNewSession = true;
  systemSent = false;   // ← reset here
}

resolveSession() (src/commands/agent/session.ts:146):

const isNewSession = !fresh && !opts.sessionId;

…and persistedThinking/persistedVerbose are also only forwarded when fresh === true.

The fix should reset systemSent (and, arguably, skillsSnapshot) when the freshness check decides to rotate the session:

sessionId = fresh ? entry.sessionId : randomUUID();
const isRolledOver = !fresh;

Then in nextEntryPatch:

systemSent: isRolledOver ? undefined : entry?.systemSent,
skillsSnapshot: isRolledOver ? undefined : entry?.skillsSnapshot,
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-methods/agent.ts
Line: 363-380

Comment:
**`systemSent` not reset on session rollover**

When `fresh` is `false` and a new `sessionId` is minted, `systemSent` is still carried over from the stale entry via `nextEntryPatch` (line ~406: `systemSent: entry?.systemSent`). If the old session had `systemSent: true`, the new session (with an empty conversation history identified by the new UUID) will skip system prompt injection — leaving the agent without its persona and instructions for the first turn.

Both reference implementations handle this explicitly:

`resolveCronSession()` (src/cron/isolated-agent/session.ts:50-54):
```typescript
} else {
  // Session expired, create new
  sessionId = crypto.randomUUID();
  isNewSession = true;
  systemSent = false;   // ← reset here
}
```

`resolveSession()` (src/commands/agent/session.ts:146):
```typescript
const isNewSession = !fresh && !opts.sessionId;
```
…and `persistedThinking`/`persistedVerbose` are also only forwarded when `fresh === true`.

The fix should reset `systemSent` (and, arguably, `skillsSnapshot`) when the freshness check decides to rotate the session:

```typescript
sessionId = fresh ? entry.sessionId : randomUUID();
const isRolledOver = !fresh;
```

Then in `nextEntryPatch`:
```typescript
systemSent: isRolledOver ? undefined : entry?.systemSent,
skillsSnapshot: isRolledOver ? undefined : entry?.skillsSnapshot,
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +376 to +380
});
sessionId = fresh ? entry.sessionId : randomUUID();
} else {
sessionId = entry?.sessionId ?? randomUUID();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bootstrap cache not evicted on rollover

Both resolveCronSession() and resolveSession() call clearBootstrapSnapshotOnSessionRollover whenever a session transitions to a new ID, ensuring the workspace bootstrap file cache is evicted. This code path skips that step entirely.

By the time agentCommandFromIngress runs, the store already has updatedAt: now with the new UUID, so resolveSession() will see fresh = true and isNewSession = false — meaning its own clearBootstrapSnapshotOnSessionRollover call is also a no-op (guarded by the isNewSession flag). The eviction is therefore never performed.

The fix should import clearBootstrapSnapshotOnSessionRollover from ../../agents/bootstrap-cache.js and call it right after the rollover is detected (when !fresh), passing canonicalKey as the session key and the old entry.sessionId as the previous session ID.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-methods/agent.ts
Line: 376-380

Comment:
**Bootstrap cache not evicted on rollover**

Both `resolveCronSession()` and `resolveSession()` call `clearBootstrapSnapshotOnSessionRollover` whenever a session transitions to a new ID, ensuring the workspace bootstrap file cache is evicted. This code path skips that step entirely.

By the time `agentCommandFromIngress` runs, the store already has `updatedAt: now` with the new UUID, so `resolveSession()` will see `fresh = true` and `isNewSession = false` — meaning its own `clearBootstrapSnapshotOnSessionRollover` call is also a no-op (guarded by the `isNewSession` flag). The eviction is therefore never performed.

The fix should import `clearBootstrapSnapshotOnSessionRollover` from `../../agents/bootstrap-cache.js` and call it right after the rollover is detected (when `!fresh`), passing `canonicalKey` as the session key and the old `entry.sessionId` as the previous session ID.

How can I resolve this? If you propose a fix, please make it concise.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7db18a8a28

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

now,
policy,
});
sessionId = fresh ? entry.sessionId : randomUUID();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear transcript linkage when rotating stale session IDs

When this branch marks a stale entry and assigns randomUUID(), the rest of the handler still merges into the existing session entry without clearing sessionFile, so the next run keeps using the prior transcript path instead of starting a fresh transcript. In the stale-session case (the exact path this patch introduces), resolveSessionTranscriptFile will keep reusing the old file from the persisted entry, so daily/idle rollover can still carry forward old history and continue growing the same transcript rather than performing a true reset.

Useful? React with 👍 / 👎.

@steipete steipete added duplicate This issue or pull request already exists close:duplicate Closed as duplicate labels Apr 26, 2026
@steipete

Copy link
Copy Markdown
Contributor

Thanks for tracing the gateway-agent bypass. I’m closing this as superseded by #71845.

What changed in the replacement:

  • the gateway agent path now evaluates session freshness before reusing a requested/stored session id
  • the freshness calculation uses the new lifecycle fields (sessionStartedAt for daily reset, lastInteractionAt for idle reset) instead of updatedAt
  • stale gateway-agent sessions roll to a new sessionId even if bookkeeping recently touched the row
  • the same lifecycle model is applied across auto-reply, agent command, cron, and session-store writes
  • added a gateway regression test for “stale session rolls even when updatedAt is recent”

This PR was a real bug report/fix direction, but the replacement is broader and includes the missing lifecycle model needed for the rest of the duplicate cluster. Closing in favor of #71845.

@steipete steipete closed this Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

close:duplicate Closed as duplicate duplicate This issue or pull request already exists gateway Gateway runtime size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants