Skip to content

Commit 27af295

Browse files
committed
fix(reply): serialize session initialization retries
1 parent 1bf7951 commit 27af295

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,
@@ -190,6 +191,14 @@ export type InitSessionStateParams = {
190191
resumeRequestedSession?: boolean;
191192
};
192193

194+
type InitSessionStateAttemptContext = {
195+
agentId: string;
196+
conversationBindingContext: ReturnType<typeof resolveSessionConversationBindingContext>;
197+
isSystemEvent: boolean;
198+
sessionCtxForState: MsgContext;
199+
storePath: string;
200+
};
201+
193202
function resolveSessionConversationBindingContext(
194203
cfg: OpenClawConfig,
195204
ctx: MsgContext,
@@ -248,32 +257,19 @@ function resolveBoundConversationSessionKey(params: {
248257
return binding.targetSessionKey;
249258
}
250259

251-
/** Initializes or reuses the reply session state for one inbound turn. */
252-
export async function initSessionState(params: InitSessionStateParams): Promise<SessionInitResult> {
253-
return await initSessionStateAttempt(params, 0);
254-
}
255-
256-
async function initSessionStateAttempt(
260+
function resolveInitSessionStateAttemptContext(
257261
params: InitSessionStateParams,
258-
staleSnapshotRetries: number,
259-
): Promise<SessionInitResult> {
260-
const { ctx, cfg, commandAuthorized } = params;
261-
// Heartbeat, cron-event, and exec-event runs should NEVER trigger session
262-
// resets or conversation binding retargeting. These are automated system
263-
// events, not user interactions that should affect session continuity.
264-
// See #58409 for details on silent session reset bug.
262+
): InitSessionStateAttemptContext {
263+
const { cfg, ctx } = params;
264+
// Automated system events must not reset sessions or retarget conversation bindings.
265265
const isSystemEvent =
266266
ctx.Provider === "heartbeat" || ctx.Provider === "cron-event" || ctx.Provider === "exec-event";
267267
const conversationBindingContext = isSystemEvent
268268
? null
269269
: resolveSessionConversationBindingContext(cfg, ctx);
270-
// Native slash commands (Telegram/Discord/Slack) are delivered on a separate
271-
// "slash session" key, but should mutate the target chat session.
270+
// Slash/menu commands may arrive on a transport session while targeting the chat session.
271+
// Prefer explicit command target before binding lookup so command mutations land there.
272272
const commandTargetSessionKey = resolveCommandTurnTargetSessionKey(ctx);
273-
// Native slash/menu commands can arrive on a transport-specific "slash session"
274-
// while explicitly targeting an existing chat session. Honor that explicit target
275-
// before any binding lookup so command-side mutations land on the intended session.
276-
// Priority: commandTargetSessionKey > boundConversation > route.
277273
const targetSessionKey =
278274
commandTargetSessionKey ??
279275
resolveBoundConversationSessionKey({
@@ -285,20 +281,54 @@ async function initSessionStateAttempt(
285281
targetSessionKey && targetSessionKey !== ctx.SessionKey
286282
? { ...ctx, SessionKey: targetSessionKey }
287283
: ctx;
288-
const sessionCfg = cfg.session;
289-
const maintenanceConfig = resolveMaintenanceConfigFromInput(sessionCfg?.maintenance);
290-
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
291284
const agentId = resolveSessionAgentId({
292285
sessionKey: sessionCtxForState.SessionKey,
293286
config: cfg,
294287
fallbackAgentId: sessionCtxForState.AgentId,
295288
});
289+
return {
290+
agentId,
291+
conversationBindingContext,
292+
isSystemEvent,
293+
sessionCtxForState,
294+
storePath: resolveStorePath(cfg.session?.store, { agentId }),
295+
};
296+
}
297+
298+
/** Initializes or reuses the reply session state for one inbound turn. */
299+
export async function initSessionState(params: InitSessionStateParams): Promise<SessionInitResult> {
300+
return await initSessionStateAttempt(params, 0);
301+
}
302+
303+
async function initSessionStateAttempt(
304+
params: InitSessionStateParams,
305+
staleSnapshotRetries: number,
306+
): Promise<SessionInitResult> {
307+
const attemptContext = resolveInitSessionStateAttemptContext(params);
308+
// Guarded revision checks only serialize correctly when the snapshot and
309+
// commit share the same writer lane.
310+
return await runExclusiveSessionStoreWrite(
311+
attemptContext.storePath,
312+
async () => await initSessionStateAttemptLocked(params, attemptContext, staleSnapshotRetries),
313+
);
314+
}
315+
316+
async function initSessionStateAttemptLocked(
317+
params: InitSessionStateParams,
318+
attemptContext: InitSessionStateAttemptContext,
319+
staleSnapshotRetries: number,
320+
): Promise<SessionInitResult> {
321+
const { ctx, cfg, commandAuthorized } = params;
322+
const { agentId, conversationBindingContext, isSystemEvent, sessionCtxForState, storePath } =
323+
attemptContext;
324+
const sessionCfg = cfg.session;
325+
const maintenanceConfig = resolveMaintenanceConfigFromInput(sessionCfg?.maintenance);
326+
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
296327
const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined;
297328
const resetTriggers = sessionCfg?.resetTriggers?.length
298329
? sessionCfg.resetTriggers
299330
: DEFAULT_RESET_TRIGGERS;
300331
const sessionScope = sessionCfg?.scope ?? "per-sender";
301-
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
302332
const ingressTimingEnabled = process.env.OPENCLAW_DEBUG_INGRESS_TIMING === "1";
303333

304334
let sessionEntry: SessionEntry;
@@ -862,7 +892,7 @@ async function initSessionStateAttempt(
862892
});
863893
if (!committed.ok) {
864894
if (staleSnapshotRetries < REPLY_SESSION_INIT_STALE_SNAPSHOT_RETRIES) {
865-
return await initSessionStateAttempt(params, staleSnapshotRetries + 1);
895+
return await initSessionStateAttemptLocked(params, attemptContext, staleSnapshotRetries + 1);
866896
}
867897
throw new Error(`reply session initialization conflicted for ${sessionKey}`);
868898
}

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)