openclaw - ✅(Solved) Fix [Bug]: Compaction/flush metadata writes advance updatedAt, breaking daily and idle session reset for legacy entries [1 pull requests, 1 comments, 2 participants]

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…
GitHub stats
openclaw/openclaw#75037Fetched 2026-05-01 05:38:54
View on GitHub
Comments
1
Participants
2
Timeline
2
Reactions
2
Timeline (top)
commented ×1cross-referenced ×1

incrementCompactionCount explicitly writes updatedAt: now, and updateSessionStoreEntry (via default mergeSessionEntry) implicitly advances updatedAt to now when persisting flush metadata — causing evaluateSessionFreshness to misjudge stale sessions as fresh. Daily reset is skipped for legacy sessions missing sessionStartedAt, and idle reset is skipped for legacy sessions missing both lastInteractionAt and sessionStartedAt.

Root Cause

/**
 * Bug reproduction: `incrementCompactionCount` and `updateSessionStoreEntry`
 * (via default `mergeSessionEntry`) advance `updatedAt` past reset boundaries,
 * causing `evaluateSessionFreshness` to misjudge stale sessions as fresh.
 *
 * ## Root cause
 *
 * `runMemoryFlushIfNeeded` runs as a synthetic agent turn via
 * `runEmbeddedPiAgent` — it does NOT go through `initSessionState`.
 * However, two post-flush storage writes advance `updatedAt`:
 *
 * 1. `incrementCompactionCount` (session-updates.ts) explicitly writes
 *    `updatedAt: now` in the patch.
 * 2. `updateSessionStoreEntry` (store.ts) merges flush metadata
 *    ({memoryFlushAt, memoryFlushCompactionCount}) via `mergeSessionEntry`,
 *    whose default strategy `resolveMergedUpdatedAt` returns
 *    `Math.max(existingUpdatedAt, patchUpdatedAt, now)` — always advancing
 *    `updatedAt` to `now` even when the patch does not include it.
 *
 * When the real user message subsequently arrives and `initSessionState`
 * re-evaluates freshness via `evaluateSessionFreshness`:
 *
 * - **Daily mode**: `sessionStartedAt` falls back to `updatedAt` for legacy
 *   entries lacking `sessionStartedAt`. After the flush advanced `updatedAt`
 *   past the boundary, `sessionStartedAt` resolves to the fresh timestamp →
 *   session judged as "started after the boundary" → no reset.
 *
 * - **Idle mode**: For legacy entries lacking both `lastInteractionAt` and
 *   `sessionStartedAt`, the fallback chain
 *   `lastInteractionAt ?? sessionStartedAt ?? updatedAt` ultimately picks up
 *   the advanced `updatedAt` → idle timer appears to have been reset recently
 *   → session judged fresh → no reset.
 *
 * ## Scenario timeline (daily reset, atHour=4)
 *
 * ```
 * 2026-04-27 21:16  Last real user message (updatedAt written)
 *                   (overnight, 04:00 boundary passes)
 * 2026-04-28 08:28  incrementCompactionCount → updatedAt = 08:28
 *                   updateSessionStoreEntry({memoryFlushAt}) → mergeSessionEntry
 *                     → updatedAt = max(08:28, now) = 08:28
 * 2026-04-28 08:29  Real user message arrives
 *                   evaluateSessionFreshness():
 *                     sessionStartedAt fallback = updatedAt = 08:28
 *                     dailyResetAt = 04:00 on 2026-04-28
 *                     staleDaily = sessionStartedAt(08:28) < dailyResetAt(04:00) → false
 *                     → fresh = true → NO RESET (BUG)
 * ```
 *
 * ## Existing precedent for the fix
 *
 * `mergeSessionEntryPreserveActivity` (types.ts:466) and `updateLastRoute`
 * (store.ts:819) already use "preserve-activity" merge policy with the comment:
 * "Route updates must not refresh activity timestamps; idle/daily reset
 * evaluation relies on updatedAt from actual session turns (#49515)."
 *
 * The same pattern should apply to compaction/flush metadata writes.
 */

Fix Action

Fix / Workaround

  1. Have a session created before the 04:00 daily boundary (e.g. 23:00 previous day) that lacks a sessionStartedAt field (legacy data or first-run edge case)
  2. Session accumulates context near the compaction threshold
  3. At 04:30, compaction completes → incrementCompactionCount (session-updates.ts:255) writes { compactionCount: N, updatedAt: now } where now = 04:30
  4. Additionally, updateSessionStoreEntry persists flush metadata { memoryFlushAt, memoryFlushCompactionCount }mergeSessionEntry's default resolveMergedUpdatedAt returns Math.max(existing, patch, now), advancing updatedAt to 04:30 even though the patch did not include it
  5. Next real user message → evaluateSessionFreshness falls back sessionStartedAt ?? updatedAt → picks the advanced updatedAt (04:30) which is after the 04:00 boundary → staleDaily = false → no reset
/**
 * Bug reproduction: `incrementCompactionCount` and `updateSessionStoreEntry`
 * (via default `mergeSessionEntry`) advance `updatedAt` past reset boundaries,
 * causing `evaluateSessionFreshness` to misjudge stale sessions as fresh.
 *
 * ## Root cause
 *
 * `runMemoryFlushIfNeeded` runs as a synthetic agent turn via
 * `runEmbeddedPiAgent` — it does NOT go through `initSessionState`.
 * However, two post-flush storage writes advance `updatedAt`:
 *
 * 1. `incrementCompactionCount` (session-updates.ts) explicitly writes
 *    `updatedAt: now` in the patch.
 * 2. `updateSessionStoreEntry` (store.ts) merges flush metadata
 *    ({memoryFlushAt, memoryFlushCompactionCount}) via `mergeSessionEntry`,
 *    whose default strategy `resolveMergedUpdatedAt` returns
 *    `Math.max(existingUpdatedAt, patchUpdatedAt, now)` — always advancing
 *    `updatedAt` to `now` even when the patch does not include it.
 *
 * When the real user message subsequently arrives and `initSessionState`
 * re-evaluates freshness via `evaluateSessionFreshness`:
 *
 * - **Daily mode**: `sessionStartedAt` falls back to `updatedAt` for legacy
 *   entries lacking `sessionStartedAt`. After the flush advanced `updatedAt`
 *   past the boundary, `sessionStartedAt` resolves to the fresh timestamp →
 *   session judged as "started after the boundary" → no reset.
 *
 * - **Idle mode**: For legacy entries lacking both `lastInteractionAt` and
 *   `sessionStartedAt`, the fallback chain
 *   `lastInteractionAt ?? sessionStartedAt ?? updatedAt` ultimately picks up
 *   the advanced `updatedAt` → idle timer appears to have been reset recently
 *   → session judged fresh → no reset.
 *
 * ## Scenario timeline (daily reset, atHour=4)
 *
 * ```
 * 2026-04-27 21:16  Last real user message (updatedAt written)
 *                   (overnight, 04:00 boundary passes)
 * 2026-04-28 08:28  incrementCompactionCount → updatedAt = 08:28
 *                   updateSessionStoreEntry({memoryFlushAt}) → mergeSessionEntry
 *                     → updatedAt = max(08:28, now) = 08:28
 * 2026-04-28 08:29  Real user message arrives
 *                   evaluateSessionFreshness():
 *                     sessionStartedAt fallback = updatedAt = 08:28
 *                     dailyResetAt = 04:00 on 2026-04-28
 *                     staleDaily = sessionStartedAt(08:28) < dailyResetAt(04:00) → false
 *                     → fresh = true → NO RESET (BUG)
 * ```
 *
 * ## Existing precedent for the fix
 *
 * `mergeSessionEntryPreserveActivity` (types.ts:466) and `updateLastRoute`
 * (store.ts:819) already use "preserve-activity" merge policy with the comment:
 * "Route updates must not refresh activity timestamps; idle/daily reset
 * evaluation relies on updatedAt from actual session turns (#49515)."
 *
 * The same pattern should apply to compaction/flush metadata writes.
 */

- `incrementCompactionCount` (session-updates.ts:255) explicitly includes `updatedAt: now` in its update patch
- `updateSessionStoreEntry` (store.ts:688) uses `mergeSessionEntry` whose default `resolveMergedUpdatedAt` returns `Math.max(existing, patch, now)` — advancing `updatedAt` even for metadata-only patches
- Legacy sessions without `sessionStartedAt` survive past the daily boundary when compaction/flush advances `updatedAt`
- Legacy sessions without `lastInteractionAt` and `sessionStartedAt` survive past idle timeout when compaction/flush advances `updatedAt`

PR fix notes

PR #75066: fix(sessions): preserve activity for compaction metadata

Description (problem / solution / changelog)

Summary

Describe the problem and fix in 2–5 bullets:

If this PR fixes a plugin beta-release blocker, title it fix(<plugin-id>): beta blocker - <summary> and link the matching Beta blocker: <plugin-name> - <summary> issue labeled beta-blocker. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.

  • Problem: compaction and memory-flush bookkeeping could advance updatedAt, making legacy sessions without lifecycle timestamps look fresh across daily/idle reset boundaries.
  • Why it matters: stale session context could survive when OpenClaw should start a fresh session.
  • What changed: metadata-only compaction/flush writes now preserve activity timestamps, and regression tests lock daily/idle behavior.
  • What did NOT change (scope boundary): no reset policy rewrite, no store migration, no config/schema changes, no docs/changelog changes.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #75037
  • Related #
  • This PR fixes a bug or regression

Root Cause (if applicable)

For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write N/A. If the cause is unclear, write Unknown.

  • Root cause: internal compaction/flush metadata writes used the normal session merge path, or explicitly set updatedAt, even though these writes are bookkeeping and not user activity.
  • Missing detection / guardrail: there was no regression coverage for legacy rows missing sessionStartedAt / lastInteractionAt after metadata-only writes.
  • Contributing context (if known): updatedAt is still a legacy fallback for reset freshness when explicit lifecycle timestamps cannot be recovered.

Regression Test Plan (if applicable)

For bug fixes or regressions, name the smallest reliable test coverage that should catch this. Otherwise write N/A.

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
    • Target test or file: src/config/sessions.test.ts, src/auto-reply/reply/reply-state.test.ts, src/auto-reply/ reply/session.compaction-daily-reset-race.test.ts, src/agents/pi-embedded- subscribe.handlers.compaction.test.ts
    • Scenario the test should lock in: metadata-only compaction/flush writes preserve updatedAt, and legacy daily/idle sessions still reset after their boundary.
    • Why this is the smallest reliable guardrail: it tests the store merge seam plus the session reset decision without broad runner/e2e setup.
    • Existing test that already covers this (if any): existing reset and heartbeat tests covered related behavior, but not compaction/flush metadata writes against legacy rows.
    • If no new test is added, why not: N/A

User-visible / Behavior Changes

Legacy sessions that should expire by daily or idle reset will now expire even if compaction or memory-flush metadata was written shortly before the next user message.

Diagram (if applicable)

For UI changes or non-trivial logic flows, include a small ASCII diagram reviewers can scan quickly. Otherwise write N/A.

  Before:
  [compaction/flush metadata] -> updatedAt refreshed -> legacy reset fallback sees fresh session

  After:
  [compaction/flush metadata] -> updatedAt preserved -> legacy reset fallback sees stale session -> reset

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)
  • If any Yes, explain risk + mitigation: N/A

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: local repo, Node/pnpm via repo scripts
  • Model/provider: N/A
  • Integration/channel (if any): N/A
  • Relevant config (redacted): session.reset daily or idle mode

Steps

  1. Create a legacy session row missing sessionStartedAt; for idle, also missing lastInteractionAt.
  2. Persist compaction or memory-flush metadata after a reset boundary or inside the idle window.
  3. Send the next real user message through session initialization.

Expected

  • The legacy session resets when daily or idle policy says it is stale.

Actual

  • Before this fix, metadata writes could refresh updatedAt, causing stale legacy sessions to be treated as fresh.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios: targeted session store, compaction bookkeeping, memory flush metadata, heartbeat reset guard, and legacy daily/idle reset regression tests.
  • Edge cases checked: default updateSessionStoreEntry still refreshes activity; preserve-activity path only applies to selected metadata callers; modern idle rows use lastInteractionAt.
  • What you did not verify: Testbox pnpm check:changed, because blacksmith CLI was not installed locally.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)
  • If yes, exact upgrade steps: N/A

Risks and Mitigations

List only real risks for this PR. Add/remove entries as needed. If none, write None.

  • Risk: a metadata caller that should represent real activity could accidentally use preserve-activity.
    • Mitigation: default merge behavior remains unchanged; only compaction/flush bookkeeping callers opt in, with focused tests.

Built with Codex

Changed files

  • src/agents/pi-embedded-subscribe.handlers.compaction.runtime.ts (modified, +2/-2)
  • src/agents/pi-embedded-subscribe.handlers.compaction.test.ts (modified, +7/-0)
  • src/auto-reply/reply/agent-runner-memory.ts (modified, +1/-0)
  • src/auto-reply/reply/reply-state.test.ts (modified, +20/-0)
  • src/auto-reply/reply/session-updates.ts (modified, +2/-3)
  • src/auto-reply/reply/session.compaction-daily-reset-race.test.ts (added, +159/-0)
  • src/config/sessions.test.ts (modified, +47/-0)
  • src/config/sessions/store.ts (modified, +5/-1)

Code Example

/**
 * Bug reproduction: `incrementCompactionCount` and `updateSessionStoreEntry`
 * (via default `mergeSessionEntry`) advance `updatedAt` past reset boundaries,
 * causing `evaluateSessionFreshness` to misjudge stale sessions as fresh.
 *
 * ## Root cause
 *
 * `runMemoryFlushIfNeeded` runs as a synthetic agent turn via
 * `runEmbeddedPiAgent` — it does NOT go through `initSessionState`.
 * However, two post-flush storage writes advance `updatedAt`:
 *
 * 1. `incrementCompactionCount` (session-updates.ts) explicitly writes
 *    `updatedAt: now` in the patch.
 * 2. `updateSessionStoreEntry` (store.ts) merges flush metadata
 *    ({memoryFlushAt, memoryFlushCompactionCount}) via `mergeSessionEntry`,
 *    whose default strategy `resolveMergedUpdatedAt` returns
 *    `Math.max(existingUpdatedAt, patchUpdatedAt, now)` — always advancing
 *    `updatedAt` to `now` even when the patch does not include it.
 *
 * When the real user message subsequently arrives and `initSessionState`
 * re-evaluates freshness via `evaluateSessionFreshness`:
 *
 * - **Daily mode**: `sessionStartedAt` falls back to `updatedAt` for legacy
 *   entries lacking `sessionStartedAt`. After the flush advanced `updatedAt`
 *   past the boundary, `sessionStartedAt` resolves to the fresh timestamp →
 *   session judged as "started after the boundary" → no reset.
 *
 * - **Idle mode**: For legacy entries lacking both `lastInteractionAt` and
 *   `sessionStartedAt`, the fallback chain
 *   `lastInteractionAt ?? sessionStartedAt ?? updatedAt` ultimately picks up
 *   the advanced `updatedAt` → idle timer appears to have been reset recently
 *   → session judged fresh → no reset.
 *
 * ## Scenario timeline (daily reset, atHour=4)
 *
 *

---

*
 * ## Existing precedent for the fix
 *
 * `mergeSessionEntryPreserveActivity` (types.ts:466) and `updateLastRoute`
 * (store.ts:819) already use "preserve-activity" merge policy with the comment:
 * "Route updates must not refresh activity timestamps; idle/daily reset
 * evaluation relies on updatedAt from actual session turns (#49515)."
 *
 * The same pattern should apply to compaction/flush metadata writes.
 */

import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.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";

vi.mock("../../plugin-sdk/browser-maintenance.js", () => ({
  closeTrackedBrowserTabsForSessions: vi.fn(async () => 0),
}));

describe("initSessionState — compaction/flush updatedAt advancement should not prevent daily/idle reset", () => {
  let tempDir: string;
  let storePath: string;

  beforeEach(async () => {
    tempDir = await fs.mkdtemp("/tmp/openclaw-test-compaction-race-");
    storePath = path.join(tempDir, "sessions.json");
  });

  afterEach(async () => {
    await fs.rm(tempDir, { recursive: true, force: true });
  });

  const SESSION_KEY = "main:user123";

  const createConfig = (resetOverride?: OpenClawConfig["session"]): OpenClawConfig => ({
    agents: {
      defaults: { workspace: tempDir },
      list: [{ id: "main", workspace: tempDir }],
    },
    session: {
      store: storePath,
      reset: { mode: "daily", atHour: 4 },
      ...resetOverride,
    },
    channels: {},
    gateway: {
      port: 18789,
      mode: "local",
      bind: "loopback",
      auth: { mode: "token", token: "test" },
    },
    plugins: { entries: {} },
  });

  const createCtx = (overrides?: Partial<MsgContext>): MsgContext => ({
    Body: "test message",
    From: "user123",
    To: "bot123",
    SessionKey: SESSION_KEY,
    Provider: "quietchat",
    Surface: "quietchat",
    ChatType: "direct",
    CommandAuthorized: true,
    ...overrides,
  });

  const saveSession = (
    sessionId: string,
    overrides: Partial<SessionEntry> = {},
  ): Promise<void> =>
    saveSessionStore(storePath, {
      [SESSION_KEY]: {
        sessionId,
        updatedAt: Date.now(),
        systemSent: true,
        ...overrides,
      },
    });

  // Scenario A: Daily reset — legacy entry without sessionStartedAt
  describe("daily reset mode — legacy entry without sessionStartedAt", () => {
    it("should reset the session even when compaction advanced updatedAt past the daily boundary", async () => {
      const now = Date.now();
      const todayAtHour4 = new Date(now);
      todayAtHour4.setHours(4, 0, 0, 0);

      const boundaryMs = now >= todayAtHour4.getTime()
        ? todayAtHour4.getTime()
        : todayAtHour4.getTime() - 24 * 3600_000;

      const compactionWriteAt = boundaryMs + 4 * 3600_000;

      await saveSession("legacy-daily-session-abc", {
        updatedAt: compactionWriteAt,
      });

      const cfg = createConfig({ reset: { mode: "daily", atHour: 4 } });

      const result = await initSessionState({
        ctx: createCtx({ Body: "good morning" }),
        cfg,
        commandAuthorized: true,
      });

      // BUG: updatedAt was advanced past boundary by compaction →
      // sessionStartedAt fallback = updatedAt → session judged fresh → no reset.
      expect(result.isNewSession).toBe(true);
      expect(result.sessionId).not.toBe("legacy-daily-session-abc");
    });
  });

  // Scenario B: Daily reset — entry WITH sessionStartedAt (control, passes)
  describe("daily reset mode — entry with proper sessionStartedAt (control)", () => {
    it("resets correctly because sessionStartedAt is preserved despite compaction advancing updatedAt", async () => {
      const now = Date.now();
      const todayAtHour4 = new Date(now);
      todayAtHour4.setHours(4, 0, 0, 0);
      const boundaryMs = now >= todayAtHour4.getTime()
        ? todayAtHour4.getTime()
        : todayAtHour4.getTime() - 24 * 3600_000;

      const sessionStartedYesterday = boundaryMs - 15 * 3600_000;
      const compactionWriteAt = boundaryMs + 4 * 3600_000;

      await saveSession("proper-daily-session-def", {
        updatedAt: compactionWriteAt,
        sessionStartedAt: sessionStartedYesterday,
        lastInteractionAt: sessionStartedYesterday,
      });

      const cfg = createConfig({ reset: { mode: "daily", atHour: 4 } });

      const result = await initSessionState({
        ctx: createCtx({ Body: "good morning" }),
        cfg,
        commandAuthorized: true,
      });

      expect(result.isNewSession).toBe(true);
      expect(result.sessionId).not.toBe("proper-daily-session-def");
    });
  });

  // Scenario C: Idle reset — legacy entry lacking lastInteractionAt
  describe("idle reset mode — legacy entry without lastInteractionAt", () => {
    it("should reset an idle session even when flush metadata writes advanced updatedAt", async () => {
      const now = Date.now();
      const idleMinutes = 5;
      const lastRealInteraction = now - 10 * 60_000;
      const flushMetadataWriteAt = now - 30_000;

      await saveSession("idle-legacy-ghi", {
        updatedAt: flushMetadataWriteAt,
        // no sessionStartedAt, no lastInteractionAt — legacy
        // fallback chain: lastInteractionAt ?? sessionStartedAt ?? updatedAt
        // → updatedAt = 30s ago → idle = 30s < 5min → fresh
      });

      const cfg = createConfig({ reset: { mode: "idle", idleMinutes } });

      const result = await initSessionState({
        ctx: createCtx({ Body: "hello again" }),
        cfg,
        commandAuthorized: true,
      });

      // BUG: updatedAt advanced by flush metadata write (30s ago).
      // Fallback chain picks it up as lastInteractionAt → idle appears fresh.
      expect(result.isNewSession).toBe(true);
      expect(result.sessionId).not.toBe("idle-legacy-ghi");
    });

    it("resets correctly when lastInteractionAt is present and reflects real user activity (control)", async () => {
      const now = Date.now();
      const idleMinutes = 5;
      const lastRealInteraction = now - 10 * 60_000;
      const flushMetadataWriteAt = now - 30_000;

      await saveSession("idle-proper-jkl", {
        updatedAt: flushMetadataWriteAt,
        sessionStartedAt: lastRealInteraction - 3600_000,
        lastInteractionAt: lastRealInteraction,
      });

      const cfg = createConfig({ reset: { mode: "idle", idleMinutes } });

      const result = await initSessionState({
        ctx: createCtx({ Body: "hello again" }),
        cfg,
        commandAuthorized: true,
      });

      expect(result.isNewSession).toBe(true);
      expect(result.sessionId).not.toBe("idle-proper-jkl");
    });
  });

  // Scenario D: Heartbeat correctly preserves idle expiry (control, passes)
  describe("heartbeat correctly preserves idle expiry (control)", () => {
    it("heartbeat does NOT update lastInteractionAt, idle reset still fires", async () => {
      const now = Date.now();
      const staleTime = now - 10 * 60_000;

      await saveSession("heartbeat-control-mno", {
        updatedAt: staleTime,
        sessionStartedAt: staleTime - 3600_000,
        lastInteractionAt: staleTime,
      });

      const cfg = createConfig({ reset: { mode: "idle", idleMinutes: 5 } });

      const heartbeatResult = await initSessionState({
        ctx: createCtx({ Provider: "heartbeat", Body: "HEARTBEAT_OK" }),
        cfg,
        commandAuthorized: true,
      });

      expect(heartbeatResult.isNewSession).toBe(false);
      expect(heartbeatResult.sessionEntry.lastInteractionAt).toBe(staleTime);

      const storeAfterHeartbeat = loadSessionStore(storePath);
      expect(storeAfterHeartbeat[SESSION_KEY]?.lastInteractionAt).toBe(staleTime);

      const userResult = await initSessionState({
        ctx: createCtx({ Provider: "quietchat", Body: "hello" }),
        cfg,
        commandAuthorized: true,
      });

      expect(userResult.isNewSession).toBe(true);
      expect(userResult.sessionId).not.toBe("heartbeat-control-mno");
    });
  });

  // Scenario E: Daily reset — incrementCompactionCount explicitly advances updatedAt
  describe("daily reset — incrementCompactionCount explicitly advances updatedAt", () => {
    it("should reset legacy session even after compaction wrote updatedAt past the boundary", async () => {
      const now = Date.now();
      const todayAtHour4 = new Date(now);
      todayAtHour4.setHours(4, 0, 0, 0);
      const boundaryMs = now >= todayAtHour4.getTime()
        ? todayAtHour4.getTime()
        : todayAtHour4.getTime() - 24 * 3600_000;

      const postBoundaryCompactionTime = boundaryMs + 3 * 3600_000;

      await saveSession("compaction-daily-stu", {
        updatedAt: postBoundaryCompactionTime,
        compactionCount: 3,
      });

      const cfg = createConfig({ reset: { mode: "daily", atHour: 4 } });

      const result = await initSessionState({
        ctx: createCtx({ Body: "good morning" }),
        cfg,
        commandAuthorized: true,
      });

      // BUG: updatedAt = postBoundaryCompactionTime → sessionStartedAt fallback
      // = postBoundaryCompactionTime > dailyResetAt → fresh → no reset.
      expect(result.isNewSession).toBe(true);
      expect(result.sessionId).not.toBe("compaction-daily-stu");
    });
  });
});

---

❯ session.compaction-daily-reset-race.bug.test.ts (6 tests | 3 failed) 207ms
       × should reset the session even when compaction advanced updatedAt past the daily boundary
       × should reset an idle session even when flush metadata writes advanced updatedAt
       × should reset legacy session even after compaction wrote updatedAt past the boundary
       ✓ resets correctly because sessionStartedAt is preserved despite compaction advancing updatedAt
       ✓ resets correctly when lastInteractionAt is present and reflects real user activity (control)
       ✓ heartbeat does NOT update lastInteractionAt, idle reset still fires
RAW_BUFFERClick to expand / collapse

Bug type

Behavior bug (incorrect output/state without crash)

Beta release blocker

No

Summary

incrementCompactionCount explicitly writes updatedAt: now, and updateSessionStoreEntry (via default mergeSessionEntry) implicitly advances updatedAt to now when persisting flush metadata — causing evaluateSessionFreshness to misjudge stale sessions as fresh. Daily reset is skipped for legacy sessions missing sessionStartedAt, and idle reset is skipped for legacy sessions missing both lastInteractionAt and sessionStartedAt.

Steps to reproduce

Path 1 — Daily reset + legacy session (no sessionStartedAt)

  1. Have a session created before the 04:00 daily boundary (e.g. 23:00 previous day) that lacks a sessionStartedAt field (legacy data or first-run edge case)
  2. Session accumulates context near the compaction threshold
  3. At 04:30, compaction completes → incrementCompactionCount (session-updates.ts:255) writes { compactionCount: N, updatedAt: now } where now = 04:30
  4. Additionally, updateSessionStoreEntry persists flush metadata { memoryFlushAt, memoryFlushCompactionCount }mergeSessionEntry's default resolveMergedUpdatedAt returns Math.max(existing, patch, now), advancing updatedAt to 04:30 even though the patch did not include it
  5. Next real user message → evaluateSessionFreshness falls back sessionStartedAt ?? updatedAt → picks the advanced updatedAt (04:30) which is after the 04:00 boundary → staleDaily = false → no reset

Path 2 — Idle reset + legacy session (no lastInteractionAt, no sessionStartedAt)

  1. Configure idle reset with a 5-minute timeout
  2. User's last real interaction was 10 minutes ago
  3. A flush/compaction cycle writes metadata to the session store, advancing updatedAt to ~now via the same mergeSessionEntry default strategy
  4. Note: the flush turn goes through runEmbeddedPiAgent, NOT initSessionState — it does not directly write lastInteractionAt
  5. Next user message → evaluateSessionFreshness fallback chain: lastInteractionAt ?? sessionStartedAt ?? updatedAt → all missing → picks up the advanced updatedAt (~now) → idleExpiresAt = updatedAt + 5minnow < idleExpiresAt → session judged fresh → no reset

Alternatively, run the reproduction test below (6 scenarios, 3 fail confirming both paths):

<details> <summary>session.compaction-daily-reset-race.bug.test.ts (click to expand)</summary>
/**
 * Bug reproduction: `incrementCompactionCount` and `updateSessionStoreEntry`
 * (via default `mergeSessionEntry`) advance `updatedAt` past reset boundaries,
 * causing `evaluateSessionFreshness` to misjudge stale sessions as fresh.
 *
 * ## Root cause
 *
 * `runMemoryFlushIfNeeded` runs as a synthetic agent turn via
 * `runEmbeddedPiAgent` — it does NOT go through `initSessionState`.
 * However, two post-flush storage writes advance `updatedAt`:
 *
 * 1. `incrementCompactionCount` (session-updates.ts) explicitly writes
 *    `updatedAt: now` in the patch.
 * 2. `updateSessionStoreEntry` (store.ts) merges flush metadata
 *    ({memoryFlushAt, memoryFlushCompactionCount}) via `mergeSessionEntry`,
 *    whose default strategy `resolveMergedUpdatedAt` returns
 *    `Math.max(existingUpdatedAt, patchUpdatedAt, now)` — always advancing
 *    `updatedAt` to `now` even when the patch does not include it.
 *
 * When the real user message subsequently arrives and `initSessionState`
 * re-evaluates freshness via `evaluateSessionFreshness`:
 *
 * - **Daily mode**: `sessionStartedAt` falls back to `updatedAt` for legacy
 *   entries lacking `sessionStartedAt`. After the flush advanced `updatedAt`
 *   past the boundary, `sessionStartedAt` resolves to the fresh timestamp →
 *   session judged as "started after the boundary" → no reset.
 *
 * - **Idle mode**: For legacy entries lacking both `lastInteractionAt` and
 *   `sessionStartedAt`, the fallback chain
 *   `lastInteractionAt ?? sessionStartedAt ?? updatedAt` ultimately picks up
 *   the advanced `updatedAt` → idle timer appears to have been reset recently
 *   → session judged fresh → no reset.
 *
 * ## Scenario timeline (daily reset, atHour=4)
 *
 * ```
 * 2026-04-27 21:16  Last real user message (updatedAt written)
 *                   (overnight, 04:00 boundary passes)
 * 2026-04-28 08:28  incrementCompactionCount → updatedAt = 08:28
 *                   updateSessionStoreEntry({memoryFlushAt}) → mergeSessionEntry
 *                     → updatedAt = max(08:28, now) = 08:28
 * 2026-04-28 08:29  Real user message arrives
 *                   evaluateSessionFreshness():
 *                     sessionStartedAt fallback = updatedAt = 08:28
 *                     dailyResetAt = 04:00 on 2026-04-28
 *                     staleDaily = sessionStartedAt(08:28) < dailyResetAt(04:00) → false
 *                     → fresh = true → NO RESET (BUG)
 * ```
 *
 * ## Existing precedent for the fix
 *
 * `mergeSessionEntryPreserveActivity` (types.ts:466) and `updateLastRoute`
 * (store.ts:819) already use "preserve-activity" merge policy with the comment:
 * "Route updates must not refresh activity timestamps; idle/daily reset
 * evaluation relies on updatedAt from actual session turns (#49515)."
 *
 * The same pattern should apply to compaction/flush metadata writes.
 */

import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.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";

vi.mock("../../plugin-sdk/browser-maintenance.js", () => ({
  closeTrackedBrowserTabsForSessions: vi.fn(async () => 0),
}));

describe("initSessionState — compaction/flush updatedAt advancement should not prevent daily/idle reset", () => {
  let tempDir: string;
  let storePath: string;

  beforeEach(async () => {
    tempDir = await fs.mkdtemp("/tmp/openclaw-test-compaction-race-");
    storePath = path.join(tempDir, "sessions.json");
  });

  afterEach(async () => {
    await fs.rm(tempDir, { recursive: true, force: true });
  });

  const SESSION_KEY = "main:user123";

  const createConfig = (resetOverride?: OpenClawConfig["session"]): OpenClawConfig => ({
    agents: {
      defaults: { workspace: tempDir },
      list: [{ id: "main", workspace: tempDir }],
    },
    session: {
      store: storePath,
      reset: { mode: "daily", atHour: 4 },
      ...resetOverride,
    },
    channels: {},
    gateway: {
      port: 18789,
      mode: "local",
      bind: "loopback",
      auth: { mode: "token", token: "test" },
    },
    plugins: { entries: {} },
  });

  const createCtx = (overrides?: Partial<MsgContext>): MsgContext => ({
    Body: "test message",
    From: "user123",
    To: "bot123",
    SessionKey: SESSION_KEY,
    Provider: "quietchat",
    Surface: "quietchat",
    ChatType: "direct",
    CommandAuthorized: true,
    ...overrides,
  });

  const saveSession = (
    sessionId: string,
    overrides: Partial<SessionEntry> = {},
  ): Promise<void> =>
    saveSessionStore(storePath, {
      [SESSION_KEY]: {
        sessionId,
        updatedAt: Date.now(),
        systemSent: true,
        ...overrides,
      },
    });

  // Scenario A: Daily reset — legacy entry without sessionStartedAt
  describe("daily reset mode — legacy entry without sessionStartedAt", () => {
    it("should reset the session even when compaction advanced updatedAt past the daily boundary", async () => {
      const now = Date.now();
      const todayAtHour4 = new Date(now);
      todayAtHour4.setHours(4, 0, 0, 0);

      const boundaryMs = now >= todayAtHour4.getTime()
        ? todayAtHour4.getTime()
        : todayAtHour4.getTime() - 24 * 3600_000;

      const compactionWriteAt = boundaryMs + 4 * 3600_000;

      await saveSession("legacy-daily-session-abc", {
        updatedAt: compactionWriteAt,
      });

      const cfg = createConfig({ reset: { mode: "daily", atHour: 4 } });

      const result = await initSessionState({
        ctx: createCtx({ Body: "good morning" }),
        cfg,
        commandAuthorized: true,
      });

      // BUG: updatedAt was advanced past boundary by compaction →
      // sessionStartedAt fallback = updatedAt → session judged fresh → no reset.
      expect(result.isNewSession).toBe(true);
      expect(result.sessionId).not.toBe("legacy-daily-session-abc");
    });
  });

  // Scenario B: Daily reset — entry WITH sessionStartedAt (control, passes)
  describe("daily reset mode — entry with proper sessionStartedAt (control)", () => {
    it("resets correctly because sessionStartedAt is preserved despite compaction advancing updatedAt", async () => {
      const now = Date.now();
      const todayAtHour4 = new Date(now);
      todayAtHour4.setHours(4, 0, 0, 0);
      const boundaryMs = now >= todayAtHour4.getTime()
        ? todayAtHour4.getTime()
        : todayAtHour4.getTime() - 24 * 3600_000;

      const sessionStartedYesterday = boundaryMs - 15 * 3600_000;
      const compactionWriteAt = boundaryMs + 4 * 3600_000;

      await saveSession("proper-daily-session-def", {
        updatedAt: compactionWriteAt,
        sessionStartedAt: sessionStartedYesterday,
        lastInteractionAt: sessionStartedYesterday,
      });

      const cfg = createConfig({ reset: { mode: "daily", atHour: 4 } });

      const result = await initSessionState({
        ctx: createCtx({ Body: "good morning" }),
        cfg,
        commandAuthorized: true,
      });

      expect(result.isNewSession).toBe(true);
      expect(result.sessionId).not.toBe("proper-daily-session-def");
    });
  });

  // Scenario C: Idle reset — legacy entry lacking lastInteractionAt
  describe("idle reset mode — legacy entry without lastInteractionAt", () => {
    it("should reset an idle session even when flush metadata writes advanced updatedAt", async () => {
      const now = Date.now();
      const idleMinutes = 5;
      const lastRealInteraction = now - 10 * 60_000;
      const flushMetadataWriteAt = now - 30_000;

      await saveSession("idle-legacy-ghi", {
        updatedAt: flushMetadataWriteAt,
        // no sessionStartedAt, no lastInteractionAt — legacy
        // fallback chain: lastInteractionAt ?? sessionStartedAt ?? updatedAt
        // → updatedAt = 30s ago → idle = 30s < 5min → fresh
      });

      const cfg = createConfig({ reset: { mode: "idle", idleMinutes } });

      const result = await initSessionState({
        ctx: createCtx({ Body: "hello again" }),
        cfg,
        commandAuthorized: true,
      });

      // BUG: updatedAt advanced by flush metadata write (30s ago).
      // Fallback chain picks it up as lastInteractionAt → idle appears fresh.
      expect(result.isNewSession).toBe(true);
      expect(result.sessionId).not.toBe("idle-legacy-ghi");
    });

    it("resets correctly when lastInteractionAt is present and reflects real user activity (control)", async () => {
      const now = Date.now();
      const idleMinutes = 5;
      const lastRealInteraction = now - 10 * 60_000;
      const flushMetadataWriteAt = now - 30_000;

      await saveSession("idle-proper-jkl", {
        updatedAt: flushMetadataWriteAt,
        sessionStartedAt: lastRealInteraction - 3600_000,
        lastInteractionAt: lastRealInteraction,
      });

      const cfg = createConfig({ reset: { mode: "idle", idleMinutes } });

      const result = await initSessionState({
        ctx: createCtx({ Body: "hello again" }),
        cfg,
        commandAuthorized: true,
      });

      expect(result.isNewSession).toBe(true);
      expect(result.sessionId).not.toBe("idle-proper-jkl");
    });
  });

  // Scenario D: Heartbeat correctly preserves idle expiry (control, passes)
  describe("heartbeat correctly preserves idle expiry (control)", () => {
    it("heartbeat does NOT update lastInteractionAt, idle reset still fires", async () => {
      const now = Date.now();
      const staleTime = now - 10 * 60_000;

      await saveSession("heartbeat-control-mno", {
        updatedAt: staleTime,
        sessionStartedAt: staleTime - 3600_000,
        lastInteractionAt: staleTime,
      });

      const cfg = createConfig({ reset: { mode: "idle", idleMinutes: 5 } });

      const heartbeatResult = await initSessionState({
        ctx: createCtx({ Provider: "heartbeat", Body: "HEARTBEAT_OK" }),
        cfg,
        commandAuthorized: true,
      });

      expect(heartbeatResult.isNewSession).toBe(false);
      expect(heartbeatResult.sessionEntry.lastInteractionAt).toBe(staleTime);

      const storeAfterHeartbeat = loadSessionStore(storePath);
      expect(storeAfterHeartbeat[SESSION_KEY]?.lastInteractionAt).toBe(staleTime);

      const userResult = await initSessionState({
        ctx: createCtx({ Provider: "quietchat", Body: "hello" }),
        cfg,
        commandAuthorized: true,
      });

      expect(userResult.isNewSession).toBe(true);
      expect(userResult.sessionId).not.toBe("heartbeat-control-mno");
    });
  });

  // Scenario E: Daily reset — incrementCompactionCount explicitly advances updatedAt
  describe("daily reset — incrementCompactionCount explicitly advances updatedAt", () => {
    it("should reset legacy session even after compaction wrote updatedAt past the boundary", async () => {
      const now = Date.now();
      const todayAtHour4 = new Date(now);
      todayAtHour4.setHours(4, 0, 0, 0);
      const boundaryMs = now >= todayAtHour4.getTime()
        ? todayAtHour4.getTime()
        : todayAtHour4.getTime() - 24 * 3600_000;

      const postBoundaryCompactionTime = boundaryMs + 3 * 3600_000;

      await saveSession("compaction-daily-stu", {
        updatedAt: postBoundaryCompactionTime,
        compactionCount: 3,
      });

      const cfg = createConfig({ reset: { mode: "daily", atHour: 4 } });

      const result = await initSessionState({
        ctx: createCtx({ Body: "good morning" }),
        cfg,
        commandAuthorized: true,
      });

      // BUG: updatedAt = postBoundaryCompactionTime → sessionStartedAt fallback
      // = postBoundaryCompactionTime > dailyResetAt → fresh → no reset.
      expect(result.isNewSession).toBe(true);
      expect(result.sessionId).not.toBe("compaction-daily-stu");
    });
  });
});
</details>

Run with: pnpm test src/auto-reply/reply/session.compaction-daily-reset-race.bug.test.ts

Result: 3 fail (A daily-legacy, C idle-legacy-fallback, E compaction-count), 3 pass (B daily-with-sessionStartedAt, C idle-with-lastInteractionAt, D heartbeat-control).

Expected behavior

  • Daily reset should evaluate based on when the session actually started, not when internal bookkeeping last wrote to the session store
  • Idle reset should only consider real user interactions; for legacy entries lacking explicit lastInteractionAt, the fallback chain should not be polluted by internal updatedAt advances
  • Internal operations (compaction count increment, flush metadata persistence) should use mergeSessionEntryPreserveActivity — the same pattern already used by updateLastRoute (store.ts:819, #49515)

Actual behavior

  • incrementCompactionCount (session-updates.ts:255) explicitly includes updatedAt: now in its update patch
  • updateSessionStoreEntry (store.ts:688) uses mergeSessionEntry whose default resolveMergedUpdatedAt returns Math.max(existing, patch, now) — advancing updatedAt even for metadata-only patches
  • Legacy sessions without sessionStartedAt survive past the daily boundary when compaction/flush advances updatedAt
  • Legacy sessions without lastInteractionAt and sessionStartedAt survive past idle timeout when compaction/flush advances updatedAt

OpenClaw version

2026.4.27

Operating system

N/A (logic bug, platform-independent)

Install method

pnpm dev (reproduced via unit test)

Model

N/A (session management logic, model-independent)

Provider / routing chain

N/A

Additional provider/model setup details

NOT_ENOUGH_INFO

Logs, screenshots, and evidence

 ❯ session.compaction-daily-reset-race.bug.test.ts (6 tests | 3 failed) 207ms
       × should reset the session even when compaction advanced updatedAt past the daily boundary
       × should reset an idle session even when flush metadata writes advanced updatedAt
       × should reset legacy session even after compaction wrote updatedAt past the boundary
       ✓ resets correctly because sessionStartedAt is preserved despite compaction advancing updatedAt
       ✓ resets correctly when lastInteractionAt is present and reflects real user activity (control)
       ✓ heartbeat does NOT update lastInteractionAt, idle reset still fires

Relevant source locations:

  • src/auto-reply/reply/session-updates.ts:255incrementCompactionCount explicit updatedAt: now
  • src/config/sessions/types.ts:401-413resolveMergedUpdatedAt default Math.max strategy
  • src/config/sessions/types.ts:459-463mergeSessionEntry (default policy, no preserve)
  • src/config/sessions/types.ts:466-472mergeSessionEntryPreserveActivity (existing fix pattern)
  • src/config/sessions/store.ts:817-822updateLastRoute using preserve-activity (precedent, #49515)
  • src/config/sessions/reset-policy.ts:79-82evaluateSessionFreshness fallback chain
  • src/auto-reply/reply/agent-runner-memory.ts:891incrementCompactionCount call site
  • src/auto-reply/reply/agent-runner-memory.ts:924 — flush metadata updateSessionStoreEntry call site

Impact and severity

  • Affected: Sessions using daily reset mode without sessionStartedAt (legacy/migrated data); sessions using idle reset mode without lastInteractionAt and sessionStartedAt (legacy data) when compaction/flush occurs
  • Severity: Medium — session state persists incorrectly across boundaries, causing stale context accumulation and missed resets
  • Frequency: Deterministic when conditions align (high-context sessions that trigger compaction + daily/idle boundary crossing)
  • Consequence: Users get responses based on stale/accumulated context that should have been reset; daily "fresh start" and idle timeout guarantees silently broken

Additional information

Root cause analysis

The flush turn runs via runEmbeddedPiAgent and does NOT go through initSessionState. The isSystemEvent guard in initSessionState is therefore irrelevant to this bug. The real issue is two post-flush storage writes that advance updatedAt:

  1. incrementCompactionCount explicitly writes updatedAt: now in its patch (session-updates.ts:255)
  2. updateSessionStoreEntry for flush metadata uses mergeSessionEntry (default policy), whose resolveMergedUpdatedAt returns Math.max(existing, patch, now) — advancing updatedAt even when the patch contains only memoryFlushAt (store.ts:688, types.ts:412)

Existing precedent

mergeSessionEntryPreserveActivity (types.ts:466) already exists and is used by updateLastRoute (store.ts:819) with the comment: "Route updates must not refresh activity timestamps; idle/daily reset evaluation relies on updatedAt from actual session turns (#49515)." The same pattern should apply to compaction/flush metadata writes.

Suggested fix (3 changes, ~20 lines)

  1. incrementCompactionCount (session-updates.ts): Remove updatedAt: now from the explicit update patch; use mergeSessionEntryPreserveActivity instead of spread for the in-memory store update
  2. updateSessionStoreEntry (store.ts): Add an optional preserveActivity?: boolean parameter; when true, use mergeSessionEntryPreserveActivity instead of mergeSessionEntry
  3. agent-runner-memory.ts line 924: Pass preserveActivity: true to the flush metadata updateSessionStoreEntry call

This is a minimal fix (~20 lines across 3 files) that follows the existing preserve-activity pattern and does not require schema changes, store migrations, or modifications to evaluateSessionFreshness.

extent analysis

TL;DR

The issue can be fixed by modifying incrementCompactionCount and updateSessionStoreEntry to use mergeSessionEntryPreserveActivity instead of advancing updatedAt unnecessarily.

Guidance

  • Identify the locations where incrementCompactionCount and updateSessionStoreEntry are called and modify them to preserve activity timestamps.
  • Update updateSessionStoreEntry to accept an optional preserveActivity parameter and use mergeSessionEntryPreserveActivity when it's true.
  • Pass preserveActivity: true to updateSessionStoreEntry when calling it for flush metadata in agent-runner-memory.ts.
  • Review the existing precedent in updateLastRoute and mergeSessionEntryPreserveActivity to ensure consistency in handling activity timestamps.

Example

// In session-updates.ts
const incrementCompactionCount = async () => {
  // Remove updatedAt: now from the patch
  const patch = { compactionCount: N };
  // Use mergeSessionEntryPreserveActivity for in-memory store update
  const updatedSession = mergeSessionEntryPreserveActivity(existingSession, patch);
  // ...
};

// In store.ts
const updateSessionStoreEntry = async (sessionEntry, patch, preserveActivity = false) => {
  if (preserveActivity) {
    return mergeSessionEntryPreserveActivity(sessionEntry, patch);
  }
  return mergeSessionEntry(sessionEntry, patch);
};

// In agent-runner-memory.ts
const flushMetadata = async () => {
  // ...
  await updateSessionStoreEntry(sessionEntry, { memoryFlushAt }, true); // Pass preserveActivity: true
};

Notes

This fix assumes that the existing mergeSessionEntryPreserveActivity function correctly preserves activity timestamps. If this function is not correctly implemented, additional modifications may be necessary.

Recommendation

Apply the suggested fix by modifying incrementCompactionCount and `updateSessionStore

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…

FAQ

Expected behavior

  • Daily reset should evaluate based on when the session actually started, not when internal bookkeeping last wrote to the session store
  • Idle reset should only consider real user interactions; for legacy entries lacking explicit lastInteractionAt, the fallback chain should not be polluted by internal updatedAt advances
  • Internal operations (compaction count increment, flush metadata persistence) should use mergeSessionEntryPreserveActivity — the same pattern already used by updateLastRoute (store.ts:819, #49515)

Still need to ship something?

×6

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

Back to top recommendations

TRENDING

openclaw - ✅(Solved) Fix [Bug]: Compaction/flush metadata writes advance updatedAt, breaking daily and idle session reset for legacy entries [1 pull requests, 1 comments, 2 participants]