@@ -35,6 +35,7 @@ import {
3535} from "../../config/sessions/session-accessor.js" ;
3636import { resolveSessionKey } from "../../config/sessions/session-key.js" ;
3737import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js" ;
38+ import { runExclusiveSessionStoreWrite } from "../../config/sessions/store-writer.js" ;
3839import { parseSessionThreadInfoFast } from "../../config/sessions/thread-info.js" ;
3940import {
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+
193202function 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 }
0 commit comments