Skip to content

Commit d2da8c7

Browse files
committed
fix(auto-reply): serialize reply session initialization
1 parent 1aa7caf commit d2da8c7

7 files changed

Lines changed: 235 additions & 37 deletions

File tree

src/auto-reply/reply/session.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import * as bootstrapCache from "../../agents/bootstrap-cache.js";
1111
import type { OpenClawConfig } from "../../config/config.js";
1212
import type { SessionEntry } from "../../config/sessions.js";
13+
import { runExclusiveSessionStoreWrite } from "../../config/sessions/store-writer.js";
1314
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts";
1415
import {
1516
testing as sessionBindingTesting,
@@ -468,6 +469,49 @@ afterEach(async () => {
468469
resetSystemEventsForTest();
469470
await sessionMcpTesting.resetSessionMcpRuntimeManager();
470471
});
472+
describe("initSessionState guarded initialization", () => {
473+
it("serializes concurrent initializers before reading the guarded snapshot", async () => {
474+
const storePath = await createStorePath("openclaw-session-init-race-");
475+
const sessionKey = "agent:main:telegram:chat:42";
476+
await writeSessionStoreFast(storePath, {
477+
[sessionKey]: {
478+
sessionId: "existing-session",
479+
updatedAt: 100,
480+
},
481+
});
482+
const cfg = { session: { store: storePath } } as OpenClawConfig;
483+
let releaseWriter = () => {};
484+
const writerReleased = new Promise<void>((resolve) => {
485+
releaseWriter = resolve;
486+
});
487+
let markWriterStarted = () => {};
488+
const writerStarted = new Promise<void>((resolve) => {
489+
markWriterStarted = resolve;
490+
});
491+
const heldWriter = runExclusiveSessionStoreWrite(storePath, async () => {
492+
markWriterStarted();
493+
await writerReleased;
494+
});
495+
await writerStarted;
496+
497+
const turns = Array.from({ length: 8 }, (_, index) =>
498+
initSessionState({
499+
ctx: {
500+
Body: `turn ${index}`,
501+
SessionKey: sessionKey,
502+
},
503+
cfg,
504+
commandAuthorized: true,
505+
}),
506+
);
507+
508+
releaseWriter();
509+
await heldWriter;
510+
511+
await expect(Promise.all(turns)).resolves.toHaveLength(8);
512+
});
513+
});
514+
471515
describe("initSessionState thread forking", () => {
472516
it("forks a new session from the parent session file", async () => {
473517
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

src/auto-reply/reply/session.ts

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from "../../config/sessions/session-accessor.js";
3636
import { resolveSessionKey } from "../../config/sessions/session-key.js";
3737
import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js";
38+
import { runExclusiveSessionStoreWrite } from "../../config/sessions/store-writer.js";
3839
import { parseSessionThreadInfoFast } from "../../config/sessions/thread-info.js";
3940
import {
4041
DEFAULT_RESET_TRIGGERS,
@@ -186,6 +187,14 @@ export type InitSessionStateParams = {
186187
resumeRequestedSession?: boolean;
187188
};
188189

190+
type InitSessionStateAttemptContext = {
191+
agentId: string;
192+
conversationBindingContext: ReturnType<typeof resolveSessionConversationBindingContext>;
193+
isSystemEvent: boolean;
194+
sessionCtxForState: MsgContext;
195+
storePath: string;
196+
};
197+
189198
function resolveSessionConversationBindingContext(
190199
cfg: OpenClawConfig,
191200
ctx: MsgContext,
@@ -244,32 +253,19 @@ function resolveBoundConversationSessionKey(params: {
244253
return binding.targetSessionKey;
245254
}
246255

247-
/** Initializes or reuses the reply session state for one inbound turn. */
248-
export async function initSessionState(params: InitSessionStateParams): Promise<SessionInitResult> {
249-
return await initSessionStateAttempt(params, false);
250-
}
251-
252-
async function initSessionStateAttempt(
256+
function resolveInitSessionStateAttemptContext(
253257
params: InitSessionStateParams,
254-
staleSnapshotRetried: boolean,
255-
): Promise<SessionInitResult> {
256-
const { ctx, cfg, commandAuthorized } = params;
257-
// Heartbeat, cron-event, and exec-event runs should NEVER trigger session
258-
// resets or conversation binding retargeting. These are automated system
259-
// events, not user interactions that should affect session continuity.
260-
// See #58409 for details on silent session reset bug.
258+
): InitSessionStateAttemptContext {
259+
const { cfg, ctx } = params;
260+
// Automated system events must not reset sessions or retarget conversation bindings.
261261
const isSystemEvent =
262262
ctx.Provider === "heartbeat" || ctx.Provider === "cron-event" || ctx.Provider === "exec-event";
263263
const conversationBindingContext = isSystemEvent
264264
? null
265265
: resolveSessionConversationBindingContext(cfg, ctx);
266-
// Native slash commands (Telegram/Discord/Slack) are delivered on a separate
267-
// "slash session" key, but should mutate the target chat session.
266+
// Slash/menu commands may arrive on a transport session while targeting the chat session.
267+
// Prefer explicit command target before binding lookup so command mutations land there.
268268
const commandTargetSessionKey = resolveCommandTurnTargetSessionKey(ctx);
269-
// Native slash/menu commands can arrive on a transport-specific "slash session"
270-
// while explicitly targeting an existing chat session. Honor that explicit target
271-
// before any binding lookup so command-side mutations land on the intended session.
272-
// Priority: commandTargetSessionKey > boundConversation > route.
273269
const targetSessionKey =
274270
commandTargetSessionKey ??
275271
resolveBoundConversationSessionKey({
@@ -281,20 +277,54 @@ async function initSessionStateAttempt(
281277
targetSessionKey && targetSessionKey !== ctx.SessionKey
282278
? { ...ctx, SessionKey: targetSessionKey }
283279
: ctx;
284-
const sessionCfg = cfg.session;
285-
const maintenanceConfig = resolveMaintenanceConfigFromInput(sessionCfg?.maintenance);
286-
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
287280
const agentId = resolveSessionAgentId({
288281
sessionKey: sessionCtxForState.SessionKey,
289282
config: cfg,
290283
fallbackAgentId: sessionCtxForState.AgentId,
291284
});
285+
return {
286+
agentId,
287+
conversationBindingContext,
288+
isSystemEvent,
289+
sessionCtxForState,
290+
storePath: resolveStorePath(cfg.session?.store, { agentId }),
291+
};
292+
}
293+
294+
/** Initializes or reuses the reply session state for one inbound turn. */
295+
export async function initSessionState(params: InitSessionStateParams): Promise<SessionInitResult> {
296+
return await initSessionStateAttempt(params, false);
297+
}
298+
299+
async function initSessionStateAttempt(
300+
params: InitSessionStateParams,
301+
staleSnapshotRetried: boolean,
302+
): Promise<SessionInitResult> {
303+
const attemptContext = resolveInitSessionStateAttemptContext(params);
304+
// Guarded revision checks only serialize correctly when the snapshot and
305+
// commit share the same writer lane.
306+
return await runExclusiveSessionStoreWrite(
307+
attemptContext.storePath,
308+
async () => await initSessionStateAttemptLocked(params, attemptContext, staleSnapshotRetried),
309+
);
310+
}
311+
312+
async function initSessionStateAttemptLocked(
313+
params: InitSessionStateParams,
314+
attemptContext: InitSessionStateAttemptContext,
315+
staleSnapshotRetried: boolean,
316+
): Promise<SessionInitResult> {
317+
const { ctx, cfg, commandAuthorized } = params;
318+
const { agentId, conversationBindingContext, isSystemEvent, sessionCtxForState, storePath } =
319+
attemptContext;
320+
const sessionCfg = cfg.session;
321+
const maintenanceConfig = resolveMaintenanceConfigFromInput(sessionCfg?.maintenance);
322+
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
292323
const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined;
293324
const resetTriggers = sessionCfg?.resetTriggers?.length
294325
? sessionCfg.resetTriggers
295326
: DEFAULT_RESET_TRIGGERS;
296327
const sessionScope = sessionCfg?.scope ?? "per-sender";
297-
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
298328
const ingressTimingEnabled = process.env.OPENCLAW_DEBUG_INGRESS_TIMING === "1";
299329

300330
let sessionEntry: SessionEntry;
@@ -858,7 +888,7 @@ async function initSessionStateAttempt(
858888
});
859889
if (!committed.ok) {
860890
if (!staleSnapshotRetried) {
861-
return await initSessionStateAttempt(params, true);
891+
return await initSessionStateAttemptLocked(params, attemptContext, true);
862892
}
863893
throw new Error(`reply session initialization conflicted for ${sessionKey}`);
864894
}

src/config/sessions/session-accessor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1776,6 +1776,7 @@ export async function commitReplySessionInitialization(params: {
17761776
activeSessionKey: params.activeSessionKey,
17771777
maintenanceConfig: params.maintenanceConfig,
17781778
onWarn: params.onMaintenanceWarning,
1779+
reentrant: true,
17791780
skipSaveWhenResult: (result) => !result.ok,
17801781
},
17811782
);

src/config/sessions/store-writer.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,76 @@ describe("session store writer", () => {
3636
expect(getSessionStoreWriterQueueSizeForTest()).toBe(0);
3737
});
3838

39+
it("runs nested writes for the active store without requeueing behind itself", async () => {
40+
const storePath = "/tmp/openclaw-store.json";
41+
const order: string[] = [];
42+
43+
const result = await runExclusiveSessionStoreWrite(storePath, async () => {
44+
order.push("outer:start");
45+
const nested = await runExclusiveSessionStoreWrite(
46+
storePath,
47+
async () => {
48+
order.push("inner");
49+
return "nested-result";
50+
},
51+
{ reentrant: true },
52+
);
53+
order.push("outer:end");
54+
return nested;
55+
});
56+
57+
expect(result).toBe("nested-result");
58+
expect(order).toEqual(["outer:start", "inner", "outer:end"]);
59+
expect(getSessionStoreWriterQueueSizeForTest()).toBe(0);
60+
});
61+
62+
it("does not leak active writer state to async children after the writer returns", async () => {
63+
const storePath = "/tmp/openclaw-store.json";
64+
const order: string[] = [];
65+
let releaseChild = () => {};
66+
const childReleased = new Promise<void>((resolve) => {
67+
releaseChild = resolve;
68+
});
69+
let child: Promise<string> = Promise.resolve("not-started");
70+
71+
await runExclusiveSessionStoreWrite(storePath, async () => {
72+
child = (async () => {
73+
await childReleased;
74+
return await runExclusiveSessionStoreWrite(storePath, async () => {
75+
order.push("child");
76+
return "child-result";
77+
});
78+
})();
79+
});
80+
81+
let releaseBlocker = () => {};
82+
const blockerReleased = new Promise<void>((resolve) => {
83+
releaseBlocker = resolve;
84+
});
85+
let markBlockerStarted = () => {};
86+
const blockerStarted = new Promise<void>((resolve) => {
87+
markBlockerStarted = resolve;
88+
});
89+
const blocker = runExclusiveSessionStoreWrite(storePath, async () => {
90+
order.push("blocker:start");
91+
markBlockerStarted();
92+
await blockerReleased;
93+
order.push("blocker:end");
94+
});
95+
await blockerStarted;
96+
97+
releaseChild();
98+
await Promise.resolve();
99+
expect(order).toEqual(["blocker:start"]);
100+
101+
releaseBlocker();
102+
await Promise.all([blocker, child]);
103+
104+
expect(order).toEqual(["blocker:start", "blocker:end", "child"]);
105+
expect(await child).toBe("child-result");
106+
expect(getSessionStoreWriterQueueSizeForTest()).toBe(0);
107+
});
108+
39109
it("rejects empty store paths before enqueuing work", async () => {
40110
await expect(runExclusiveSessionStoreWrite("", async () => undefined)).rejects.toThrow(
41111
/storePath must be a non-empty string/,

src/config/sessions/store-writer.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@
22
import { runQueuedStoreWrite } from "../../shared/store-writer-queue.js";
33
import { WRITER_QUEUES } from "./store-writer-state.js";
44

5+
export type RunExclusiveSessionStoreWriteOptions = {
6+
reentrant?: boolean;
7+
};
8+
59
export async function runExclusiveSessionStoreWrite<T>(
610
storePath: string,
711
fn: () => Promise<T>,
12+
opts: RunExclusiveSessionStoreWriteOptions = {},
813
): Promise<T> {
914
return await runQueuedStoreWrite({
1015
queues: WRITER_QUEUES,
1116
storePath,
1217
label: "runExclusiveSessionStoreWrite",
1318
fn,
19+
reentrant: opts.reentrant,
1420
});
1521
}

src/config/sessions/store.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ type SaveSessionStoreOptions = {
204204
};
205205

206206
type UpdateSessionStoreOptions<T> = SaveSessionStoreOptions & {
207+
/** Allow a nested mutation only when the caller already owns this store writer lane. */
208+
reentrant?: boolean;
207209
/**
208210
* Specialized callers can prove their mutator made no changes through its result.
209211
* When true, the writer-owned object cache is restored and sessions.json is untouched.
@@ -1019,19 +1021,23 @@ export async function updateSessionStore<T>(
10191021
mutator: (store: Record<string, SessionEntry>) => Promise<T> | T,
10201022
opts?: UpdateSessionStoreOptions<T>,
10211023
): Promise<T> {
1022-
return await runExclusiveSessionStoreWrite(storePath, async () => {
1023-
const store = loadMutableSessionStoreForWriter(storePath);
1024-
const result = await mutator(store);
1025-
if (opts?.skipSaveWhenResult?.(result)) {
1026-
restoreUnchangedSessionStoreCache(storePath, store);
1024+
return await runExclusiveSessionStoreWrite(
1025+
storePath,
1026+
async () => {
1027+
const store = loadMutableSessionStoreForWriter(storePath);
1028+
const result = await mutator(store);
1029+
if (opts?.skipSaveWhenResult?.(result)) {
1030+
restoreUnchangedSessionStoreCache(storePath, store);
1031+
return result;
1032+
}
1033+
await saveSessionStoreUnlocked(storePath, store, {
1034+
...opts,
1035+
singleEntryPersistence: opts?.resolveSingleEntryPersistence?.(result) ?? undefined,
1036+
});
10271037
return result;
1028-
}
1029-
await saveSessionStoreUnlocked(storePath, store, {
1030-
...opts,
1031-
singleEntryPersistence: opts?.resolveSingleEntryPersistence?.(result) ?? undefined,
1032-
});
1033-
return result;
1034-
});
1038+
},
1039+
{ reentrant: opts?.reentrant },
1040+
);
10351041
}
10361042

10371043
function cloneSessionEntryProjectionSnapshot(

0 commit comments

Comments
 (0)