@@ -29,6 +29,9 @@ import { createMemoryCoreTestHarness } from "./test-helpers.js";
2929
3030const { createTempWorkspace } = createMemoryCoreTestHarness ( ) ;
3131const DREAMS_FILE_LOCKS_KEY = Symbol . for ( "openclaw.memoryCore.dreamingNarrative.fileLocks" ) ;
32+ const NARRATIVE_SESSION_LOCKS_KEY = Symbol . for (
33+ "openclaw.memoryCore.dreamingNarrative.sessionLocks" ,
34+ ) ;
3235const EXPECTS_POSIX_PRIVATE_FILE_MODE = process . platform !== "win32" ;
3336
3437type MockCallSource = { mock : { calls : Array < Array < unknown > > } } ;
@@ -80,6 +83,7 @@ async function expectPathMissing(targetPath: string): Promise<void> {
8083afterEach ( ( ) => {
8184 vi . restoreAllMocks ( ) ;
8285 resolveGlobalMap < string , unknown > ( DREAMS_FILE_LOCKS_KEY ) . clear ( ) ;
86+ resolveGlobalMap < string , unknown > ( NARRATIVE_SESSION_LOCKS_KEY ) . clear ( ) ;
8387} ) ;
8488
8589describe ( "buildNarrativePrompt" , ( ) => {
@@ -1115,10 +1119,12 @@ describe("runDetachedDreamNarrative", () => {
11151119
11161120 it ( "caps the number of in-flight detached narratives at 3" , async ( ) => {
11171121 const { subagent, runDeferreds } = createBlockingSubagent ( ) ;
1118- const workspaceDir = await createTempWorkspace ( "openclaw-dreaming-detach-" ) ;
1122+ const workspaceDirs = await Promise . all (
1123+ Array . from ( { length : 5 } , ( ) => createTempWorkspace ( "openclaw-dreaming-detach-" ) ) ,
1124+ ) ;
11191125 const logger = createMockLogger ( ) ;
11201126
1121- for ( let i = 0 ; i < 5 ; i += 1 ) {
1127+ for ( const [ i , workspaceDir ] of workspaceDirs . entries ( ) ) {
11221128 runDetachedDreamNarrative ( {
11231129 subagent,
11241130 workspaceDir,
@@ -1158,6 +1164,64 @@ describe("runDetachedDreamNarrative", () => {
11581164 expect ( subagent . waitForRun ) . toHaveBeenCalledTimes ( 5 ) ;
11591165 } ) ;
11601166
1167+ it ( "serializes detached narratives that reuse a workspace and phase session" , async ( ) => {
1168+ let nextRunId = 0 ;
1169+ const waitDeferreds : Array < Deferred < { status : string } > > = [ ] ;
1170+ const subagent = {
1171+ run : vi . fn ( ( ) => {
1172+ nextRunId += 1 ;
1173+ return Promise . resolve ( { runId : `run-${ nextRunId } ` } ) ;
1174+ } ) ,
1175+ waitForRun : vi . fn ( ( ) => {
1176+ const d = deferred < { status : string } > ( ) ;
1177+ waitDeferreds . push ( d ) ;
1178+ return d . promise ;
1179+ } ) ,
1180+ getSessionMessages : vi . fn ( ) . mockResolvedValue ( { messages : [ ] } ) ,
1181+ deleteSession : vi . fn ( ) . mockResolvedValue ( undefined ) ,
1182+ } ;
1183+ const workspaceDir = await createTempWorkspace ( "openclaw-dreaming-detach-" ) ;
1184+ const logger = createMockLogger ( ) ;
1185+
1186+ for ( let i = 0 ; i < 5 ; i += 1 ) {
1187+ runDetachedDreamNarrative ( {
1188+ subagent,
1189+ workspaceDir,
1190+ data : { phase : "light" , snippets : [ `fragment-${ i } ` ] } ,
1191+ nowMs : Date . parse ( "2026-04-28T03:00:00Z" ) ,
1192+ logger,
1193+ } ) ;
1194+ }
1195+
1196+ await vi . waitFor ( ( ) => {
1197+ expect ( waitDeferreds . length ) . toBe ( 1 ) ;
1198+ } ) ;
1199+
1200+ expect ( subagent . run ) . toHaveBeenCalledTimes ( 1 ) ;
1201+ expect ( subagent . waitForRun ) . toHaveBeenCalledTimes ( 1 ) ;
1202+ // The first run is still active, so later same-key jobs must not pre-delete its session.
1203+ expect ( subagent . deleteSession ) . toHaveBeenCalledTimes ( 1 ) ;
1204+
1205+ for ( let i = 0 ; i < 5 ; i += 1 ) {
1206+ const currentDeferred = waitDeferreds [ i ] ;
1207+ if ( ! currentDeferred ) {
1208+ throw new Error ( `Expected wait deferred ${ i } to exist` ) ;
1209+ }
1210+ currentDeferred . resolve ( { status : "timeout" } ) ;
1211+ if ( i < 4 ) {
1212+ await vi . waitFor ( ( ) => {
1213+ expect ( waitDeferreds . length ) . toBeGreaterThan ( i + 1 ) ;
1214+ } ) ;
1215+ }
1216+ }
1217+
1218+ await vi . waitFor ( ( ) => {
1219+ expect ( subagent . deleteSession ) . toHaveBeenCalledTimes ( 10 ) ;
1220+ } ) ;
1221+ expect ( subagent . run ) . toHaveBeenCalledTimes ( 5 ) ;
1222+ expect ( subagent . waitForRun ) . toHaveBeenCalledTimes ( 5 ) ;
1223+ } ) ;
1224+
11611225 it ( "swallows underlying narrative errors instead of leaving an unhandled rejection" , async ( ) => {
11621226 const error = new Error ( "boom" ) ;
11631227 const subagent = {
0 commit comments