@@ -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 ,
@@ -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+
189198function 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 }
0 commit comments