Skip to content

Commit dc14d1a

Browse files
committed
fix(memory-core): keep live reindex of reset/deleted session archives
The session transcript listener dropped every archive artifact before scheduling, so .jsonl.reset and .jsonl.deleted archives written by /reset and session delete were no longer indexed on the live path and only surfaced in memory search after the next gateway restart. Remove the archive guard so in-agent archives fall through to scheduleSessionDirty, which reaches the usage-counted-archive branch in processSessionDeltaBatch. Regression from #89912; restores the incremental archive indexing added in #76520 that originally fixed #57334.
1 parent bc24356 commit dc14d1a

2 files changed

Lines changed: 153 additions & 3 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import type { DatabaseSync } from "node:sqlite";
5+
import {
6+
resolveSessionTranscriptsDirForAgent,
7+
type OpenClawConfig,
8+
type ResolvedMemorySearchConfig,
9+
} from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
10+
import type {
11+
MemorySource,
12+
MemorySyncParams,
13+
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
14+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15+
import { MemoryManagerSyncOps } from "./manager-sync-ops.js";
16+
17+
type MemorySessionTranscriptUpdate = {
18+
agentId?: string;
19+
sessionFile?: string;
20+
sessionKey?: string;
21+
};
22+
23+
const SUBSCRIBER_KEY = Symbol.for("openclaw.memoryCore.sessionTranscriptUpdateSubscriber");
24+
25+
class ArchiveListenerHarness extends MemoryManagerSyncOps {
26+
protected readonly cfg = {} as OpenClawConfig;
27+
protected readonly agentId = "main";
28+
protected readonly workspaceDir = "/tmp/openclaw-test-workspace";
29+
protected readonly settings = {
30+
chunking: { overlap: 0, tokens: 256 },
31+
extraPaths: [],
32+
multimodal: { enabled: false, modalities: [], maxFileBytes: 0 },
33+
provider: "none",
34+
store: { fts: { tokenizer: "unicode61" }, vector: { enabled: false } },
35+
sync: { sessions: { deltaBytes: 100_000, deltaMessages: 50, postCompactionForce: true } },
36+
} as unknown as ResolvedMemorySearchConfig;
37+
protected readonly batch = {
38+
enabled: false,
39+
wait: false,
40+
concurrency: 1,
41+
pollIntervalMs: 0,
42+
timeoutMs: 0,
43+
};
44+
protected readonly vector = { enabled: false, available: false };
45+
protected readonly cache = { enabled: false };
46+
protected providerUnavailableReason?: string;
47+
protected providerLifecycle = { mode: "active" as const, providerId: "test" };
48+
protected db: DatabaseSync;
49+
50+
readonly syncCalls: MemorySyncParams[] = [];
51+
52+
constructor() {
53+
super();
54+
this.sources.add("sessions");
55+
this.db = {
56+
prepare: () => ({ all: () => [], get: () => undefined, run: () => undefined }),
57+
} as unknown as DatabaseSync;
58+
}
59+
60+
start(): void {
61+
this.ensureSessionListener();
62+
}
63+
64+
getPendingSessionFiles(): string[] {
65+
return Array.from(this.sessionPendingFiles);
66+
}
67+
68+
getDirtySessionFiles(): string[] {
69+
return Array.from(this.sessionsDirtyFiles);
70+
}
71+
72+
protected computeProviderKey(): string {
73+
return "test";
74+
}
75+
76+
protected resolveProviderIndexIdentities() {
77+
return [];
78+
}
79+
80+
protected async sync(params?: MemorySyncParams): Promise<void> {
81+
this.syncCalls.push(params ?? {});
82+
}
83+
84+
protected async withTimeout<T>(promise: Promise<T>): Promise<T> {
85+
return await promise;
86+
}
87+
88+
protected getIndexConcurrency(): number {
89+
return 1;
90+
}
91+
92+
protected pruneEmbeddingCacheIfNeeded(): void {}
93+
94+
protected resetProviderInitializationForRetry(): void {}
95+
96+
protected assertRequiredProviderAvailable(): void {}
97+
98+
protected async indexFile(_entry: unknown, _options: { source: MemorySource }): Promise<void> {}
99+
}
100+
101+
describe("session archive live reindex listener", () => {
102+
let stateDir = "";
103+
let listener: ((update: MemorySessionTranscriptUpdate) => void) | null = null;
104+
105+
beforeEach(async () => {
106+
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-archive-listener-"));
107+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
108+
(globalThis as Record<symbol, unknown>)[SUBSCRIBER_KEY] = (
109+
next: (update: MemorySessionTranscriptUpdate) => void,
110+
) => {
111+
listener = next;
112+
return () => {
113+
listener = null;
114+
};
115+
};
116+
});
117+
118+
afterEach(async () => {
119+
delete (globalThis as Record<symbol, unknown>)[SUBSCRIBER_KEY];
120+
vi.useRealTimers();
121+
vi.unstubAllEnvs();
122+
await fs.rm(stateDir, { recursive: true, force: true });
123+
});
124+
125+
it.each(["reset", "deleted"] as const)(
126+
"schedules a %s archive for live indexing when its update is emitted",
127+
async (reason) => {
128+
vi.useFakeTimers();
129+
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
130+
await fs.mkdir(sessionsDir, { recursive: true });
131+
const archive = path.join(sessionsDir, `thread.jsonl.${reason}.2026-06-23T10-00-00.000Z`);
132+
await fs.writeFile(
133+
archive,
134+
JSON.stringify({ type: "message", message: { role: "user", content: "recall fact" } }) +
135+
"\n",
136+
"utf-8",
137+
);
138+
139+
const harness = new ArchiveListenerHarness();
140+
harness.start();
141+
expect(listener).toBeTypeOf("function");
142+
143+
listener?.({ sessionFile: archive });
144+
145+
expect(harness.getPendingSessionFiles()).toContain(archive);
146+
147+
await vi.advanceTimersByTimeAsync(6000);
148+
149+
expect(harness.getDirtySessionFiles()).toContain(archive);
150+
expect(harness.syncCalls.length).toBeGreaterThan(0);
151+
},
152+
);
153+
});

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,9 +1457,6 @@ export abstract class MemoryManagerSyncOps {
14571457
return;
14581458
}
14591459
const sessionFile = update.sessionFile;
1460-
if (sessionFile && isSessionArchiveArtifactName(path.basename(sessionFile))) {
1461-
return;
1462-
}
14631460
if (sessionFile && this.isSessionFileForAgent(sessionFile)) {
14641461
this.scheduleSessionDirty(sessionFile);
14651462
return;

0 commit comments

Comments
 (0)