Skip to content

Commit ddf84e4

Browse files
committed
fix(memory): detect session drift in status mode to avoid false-clean dirty flag
Plain `openclaw memory status` skipped `ensureSessionStartupCatchup()` because status mode is transient, so unindexed on-disk session transcripts never set `sessionsDirty`. Operators saw `dirty=false` while memory search silently missed recent transcripts. Status mode now runs the detection half (no sync) in the constructor and exposes `refreshSessionStartupDirtyDetection()` so the CLI status path can await it before reading `status()`. Fixes #97814
1 parent 56c2d63 commit ddf84e4

3 files changed

Lines changed: 86 additions & 1 deletion

File tree

extensions/memory-core/src/cli.runtime.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ import fsSync from "node:fs";
33
import fs from "node:fs/promises";
44
import os from "node:os";
55
import path from "node:path";
6+
import { isUsageCountedSessionTranscriptFileName } from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
67
import type { MemoryEmbeddingProbeResult } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
78
import {
89
resolveMemoryDreamingConfig,
910
resolveMemoryLightDreamingConfig,
1011
resolveMemoryRemDreamingConfig,
1112
} from "openclaw/plugin-sdk/memory-core-host-status";
1213
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
13-
import { isUsageCountedSessionTranscriptFileName } from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
1414
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
1515
import {
1616
colorize,
@@ -720,6 +720,18 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
720720
agentId,
721721
purpose: managerPurpose,
722722
run: async (manager) => {
723+
// For plain status (no --index), ensure session-drift detection from
724+
// the constructor has settled before reading status, so unindexed
725+
// transcripts surface as dirty=true instead of a false-clean state.
726+
// The builtin MemoryIndexManager exposes this; other backends do not.
727+
if (!opts.index) {
728+
const refresher = (
729+
manager as { refreshSessionStartupDirtyDetection?: () => Promise<void> }
730+
).refreshSessionStartupDirtyDetection;
731+
if (typeof refresher === "function") {
732+
await refresher.call(manager);
733+
}
734+
}
723735
const deep = Boolean(opts.deep || opts.index);
724736
let embeddingProbe: MemoryEmbeddingProbeResult | undefined;
725737
let indexError: string | undefined;

extensions/memory-core/src/memory/index.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,6 +1240,49 @@ describe("memory index", () => {
12401240
}
12411241
});
12421242

1243+
it("reports dirty in status mode when an unindexed session transcript exists (#97814)", async () => {
1244+
try {
1245+
setMemoryIndexStateDir(path.join(workspaceDir, ".state-status-session-drift"));
1246+
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
1247+
await fs.mkdir(sessionsDir, { recursive: true });
1248+
await fs.writeFile(
1249+
path.join(sessionsDir, "session-status-drift.jsonl"),
1250+
[
1251+
JSON.stringify({
1252+
type: "session",
1253+
id: "session-status-drift",
1254+
timestamp: "2026-04-07T15:24:04.113Z",
1255+
}),
1256+
JSON.stringify({
1257+
type: "message",
1258+
message: {
1259+
role: "user",
1260+
timestamp: "2026-04-07T15:25:04.113Z",
1261+
content: [{ type: "text", text: "Status drift marker." }],
1262+
},
1263+
}),
1264+
].join("\n") + "\n",
1265+
"utf8",
1266+
);
1267+
1268+
const cfg = createCfg({ sources: ["sessions"], sessionMemory: true });
1269+
const manager = await getFreshManager(cfg, "status");
1270+
try {
1271+
await (
1272+
manager as unknown as {
1273+
refreshSessionStartupDirtyDetection: () => Promise<void>;
1274+
}
1275+
).refreshSessionStartupDirtyDetection();
1276+
const status = manager.status();
1277+
expect(status.dirty).toBe(true);
1278+
} finally {
1279+
await manager.close?.();
1280+
}
1281+
} finally {
1282+
restoreMemoryIndexStateDir();
1283+
}
1284+
});
1285+
12431286
it("keeps provider cutover vector search paused during targeted session sync", async () => {
12441287
try {
12451288
setMemoryIndexStateDir(path.join(workspaceDir, ".state-targeted-cutover"));

extensions/memory-core/src/memory/manager.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
286286
protected override sessionPendingFiles = new Set<string>();
287287
protected override sessionPendingTargets = new Map<string, MemorySessionSyncTarget>();
288288
private indexIdentityDirty = false;
289+
private sessionStartupDirtyDetectionPromise: Promise<string[]> | null = null;
289290
protected override sessionDeltas = new Map<
290291
string,
291292
{ lastSize: number; pendingBytes: number; pendingMessages: number }
@@ -435,6 +436,11 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
435436
this.batch = this.resolveBatchConfig();
436437
if (!transient) {
437438
this.ensureSessionStartupCatchup();
439+
} else if (params.purpose === "status") {
440+
// Status mode skips the full catch-up sync to stay light, but still
441+
// detects on-disk session drift so status() does not report a false
442+
// clean state when unindexed transcripts exist. See #97814.
443+
this.sessionStartupDirtyDetectionPromise = this.detectSessionStartupDirtyFilesForStatus();
438444
}
439445
} catch (err) {
440446
closeMemoryDatabase(this.db);
@@ -1111,6 +1117,30 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
11111117
});
11121118
}
11131119

1120+
/**
1121+
* Runs session-drift detection without triggering a sync, so status mode
1122+
* reports dirty=true when unindexed transcripts exist on disk. Awaits the
1123+
* detection started in the constructor; safe to call from status-path CLI
1124+
* commands before reading {@link status}. See #97814.
1125+
*/
1126+
async refreshSessionStartupDirtyDetection(): Promise<void> {
1127+
if (this.sessionStartupDirtyDetectionPromise) {
1128+
await this.sessionStartupDirtyDetectionPromise;
1129+
}
1130+
}
1131+
1132+
private async detectSessionStartupDirtyFilesForStatus(): Promise<string[]> {
1133+
if (this.closed || !this.sources.has("sessions")) {
1134+
return [];
1135+
}
1136+
try {
1137+
return await this.markSessionStartupCatchupDirtyFiles();
1138+
} catch (err) {
1139+
log.warn("memory status session drift detection failed: " + String(err));
1140+
return [];
1141+
}
1142+
}
1143+
11141144
status(): MemoryProviderStatus {
11151145
this.refreshIndexIdentityDirty({
11161146
providerKeyKnown: this.providerInitialized,

0 commit comments

Comments
 (0)