@@ -19,6 +19,7 @@ import {
1919 installPromptSubmissionLockRelease ,
2020 installSessionEventWriteLock ,
2121 installSessionExternalHookWriteLock ,
22+ resetEmbeddedAttemptSessionFilePromptGuardsForTest ,
2223} from "./attempt.session-lock.js" ;
2324
2425const lockOptions = {
@@ -32,6 +33,7 @@ const tempDirs: string[] = [];
3233
3334afterEach ( async ( ) => {
3435 resetSessionWriteLockStateForTest ( ) ;
36+ resetEmbeddedAttemptSessionFilePromptGuardsForTest ( ) ;
3537 for ( const dir of tempDirs . splice ( 0 ) ) {
3638 await fs . rm ( dir , { recursive : true , force : true } ) ;
3739 }
@@ -531,6 +533,7 @@ describe("embedded attempt session lock lifecycle", () => {
531533 } ,
532534 ) ,
533535 ) ;
536+ resetEmbeddedAttemptSessionFilePromptGuardsForTest ( ) ;
534537 await secondController . releaseForPrompt ( ) ;
535538
536539 await expect (
@@ -545,6 +548,229 @@ describe("embedded attempt session lock lifecycle", () => {
545548 expect ( releases ) . toEqual ( [ "release" , "release" , "release" ] ) ;
546549 } ) ;
547550
551+ it ( "waits for an existing prompt holder before releasing another prompt on the same session file" , async ( ) => {
552+ const sessionFile = await createTempSessionFile ( ) ;
553+ const events : string [ ] = [ ] ;
554+ let acquireCount = 0 ;
555+ const acquireSessionWriteLock = vi . fn ( async ( ) => {
556+ acquireCount += 1 ;
557+ const lockId = acquireCount ;
558+ events . push ( `acquire-${ lockId } ` ) ;
559+ return {
560+ release : vi . fn ( async ( ) => {
561+ events . push ( `release-${ lockId } ` ) ;
562+ } ) ,
563+ } ;
564+ } ) ;
565+ const firstController = await createEmbeddedAttemptSessionLockController ( {
566+ acquireSessionWriteLock,
567+ lockOptions : { ...lockOptions , sessionFile } ,
568+ } ) ;
569+
570+ await firstController . releaseForPrompt ( ) ;
571+
572+ const secondController = await createEmbeddedAttemptSessionLockController ( {
573+ acquireSessionWriteLock,
574+ lockOptions : { ...lockOptions , sessionFile } ,
575+ } ) ;
576+ let secondReleasedForPrompt = false ;
577+ const secondRelease = secondController . releaseForPrompt ( ) . then ( ( ) => {
578+ secondReleasedForPrompt = true ;
579+ } ) ;
580+ await Promise . resolve ( ) ;
581+
582+ expect ( secondReleasedForPrompt ) . toBe ( false ) ;
583+ expect ( events ) . toEqual ( [ "acquire-1" , "release-1" , "acquire-2" , "release-2" ] ) ;
584+
585+ await firstController . reacquireAfterPrompt ( ) ;
586+ await secondRelease ;
587+
588+ expect ( secondReleasedForPrompt ) . toBe ( true ) ;
589+ expect ( events ) . toEqual ( [
590+ "acquire-1" ,
591+ "release-1" ,
592+ "acquire-2" ,
593+ "release-2" ,
594+ "acquire-3" ,
595+ "acquire-4" ,
596+ "release-4" ,
597+ ] ) ;
598+ expect ( firstController . hasSessionTakeover ( ) ) . toBe ( false ) ;
599+ expect ( secondController . hasSessionTakeover ( ) ) . toBe ( false ) ;
600+ } ) ;
601+
602+ it ( "keeps later waiters behind a newly registered same-file prompt holder" , async ( ) => {
603+ const sessionFile = await createTempSessionFile ( ) ;
604+ const events : string [ ] = [ ] ;
605+ let acquireCount = 0 ;
606+ const acquireSessionWriteLock = vi . fn ( async ( ) => {
607+ acquireCount += 1 ;
608+ const lockId = acquireCount ;
609+ events . push ( `acquire-${ lockId } ` ) ;
610+ return {
611+ release : vi . fn ( async ( ) => {
612+ events . push ( `release-${ lockId } ` ) ;
613+ } ) ,
614+ } ;
615+ } ) ;
616+ const firstController = await createEmbeddedAttemptSessionLockController ( {
617+ acquireSessionWriteLock,
618+ lockOptions : { ...lockOptions , sessionFile } ,
619+ } ) ;
620+
621+ await firstController . releaseForPrompt ( ) ;
622+
623+ const secondController = await createEmbeddedAttemptSessionLockController ( {
624+ acquireSessionWriteLock,
625+ lockOptions : { ...lockOptions , sessionFile } ,
626+ } ) ;
627+ const thirdController = await createEmbeddedAttemptSessionLockController ( {
628+ acquireSessionWriteLock,
629+ lockOptions : { ...lockOptions , sessionFile } ,
630+ } ) ;
631+ let secondReleasedForPrompt = false ;
632+ let thirdReleasedForPrompt = false ;
633+ const secondRelease = secondController . releaseForPrompt ( ) . then ( ( ) => {
634+ secondReleasedForPrompt = true ;
635+ events . push ( "second released for prompt" ) ;
636+ } ) ;
637+ await Promise . resolve ( ) ;
638+ const thirdRelease = thirdController . releaseForPrompt ( ) . then ( ( ) => {
639+ thirdReleasedForPrompt = true ;
640+ events . push ( "third released for prompt" ) ;
641+ } ) ;
642+ await Promise . resolve ( ) ;
643+
644+ expect ( secondReleasedForPrompt ) . toBe ( false ) ;
645+ expect ( thirdReleasedForPrompt ) . toBe ( false ) ;
646+
647+ await firstController . reacquireAfterPrompt ( ) ;
648+ await secondRelease ;
649+ await Promise . resolve ( ) ;
650+
651+ expect ( secondReleasedForPrompt ) . toBe ( true ) ;
652+ expect ( thirdReleasedForPrompt ) . toBe ( false ) ;
653+
654+ await secondController . reacquireAfterPrompt ( ) ;
655+ await thirdRelease ;
656+
657+ expect ( thirdReleasedForPrompt ) . toBe ( true ) ;
658+ expect ( events . indexOf ( "second released for prompt" ) ) . toBeGreaterThanOrEqual ( 0 ) ;
659+ expect ( events . indexOf ( "third released for prompt" ) ) . toBeGreaterThan (
660+ events . indexOf ( "second released for prompt" ) ,
661+ ) ;
662+ expect ( firstController . hasSessionTakeover ( ) ) . toBe ( false ) ;
663+ expect ( secondController . hasSessionTakeover ( ) ) . toBe ( false ) ;
664+ expect ( thirdController . hasSessionTakeover ( ) ) . toBe ( false ) ;
665+ } ) ;
666+
667+ it ( "releases a queued prompt holder when same-file waiter reacquire times out" , async ( ) => {
668+ const sessionFile = await createTempSessionFile ( ) ;
669+ const events : string [ ] = [ ] ;
670+ let acquireCount = 0 ;
671+ const reacquireError = new SessionWriteLockTimeoutError ( {
672+ timeoutMs : lockOptions . timeoutMs ,
673+ owner : "pid=789" ,
674+ lockPath : `${ sessionFile } .lock` ,
675+ } ) ;
676+ const acquireSessionWriteLock = vi . fn ( async ( ) => {
677+ acquireCount += 1 ;
678+ const lockId = acquireCount ;
679+ events . push ( `acquire-${ lockId } ` ) ;
680+ if ( lockId === 5 ) {
681+ events . push ( "second reacquire timeout" ) ;
682+ throw reacquireError ;
683+ }
684+ return {
685+ release : vi . fn ( async ( ) => {
686+ events . push ( `release-${ lockId } ` ) ;
687+ } ) ,
688+ } ;
689+ } ) ;
690+ const firstController = await createEmbeddedAttemptSessionLockController ( {
691+ acquireSessionWriteLock,
692+ lockOptions : { ...lockOptions , sessionFile } ,
693+ } ) ;
694+
695+ await firstController . releaseForPrompt ( ) ;
696+
697+ const secondController = await createEmbeddedAttemptSessionLockController ( {
698+ acquireSessionWriteLock,
699+ lockOptions : { ...lockOptions , sessionFile } ,
700+ } ) ;
701+ const thirdController = await createEmbeddedAttemptSessionLockController ( {
702+ acquireSessionWriteLock,
703+ lockOptions : { ...lockOptions , sessionFile } ,
704+ } ) ;
705+ const secondRelease = secondController . releaseForPrompt ( ) ;
706+ await Promise . resolve ( ) ;
707+ const thirdRelease = thirdController . releaseForPrompt ( ) . then ( ( ) => "released" as const ) ;
708+ await Promise . resolve ( ) ;
709+
710+ await firstController . reacquireAfterPrompt ( ) ;
711+ await expect ( secondRelease ) . rejects . toBe ( reacquireError ) ;
712+ await expect (
713+ Promise . race ( [
714+ thirdRelease ,
715+ new Promise < "blocked" > ( ( resolve ) => {
716+ setTimeout ( ( ) => resolve ( "blocked" ) , 50 ) ;
717+ } ) ,
718+ ] ) ,
719+ ) . resolves . toBe ( "released" ) ;
720+
721+ expect ( events ) . toContain ( "second reacquire timeout" ) ;
722+ expect ( thirdController . hasSessionTakeover ( ) ) . toBe ( false ) ;
723+ } ) ;
724+
725+ it ( "does not keep a prompt holder after a compaction wait release reacquires through writes" , async ( ) => {
726+ const sessionFile = await createTempSessionFile ( ) ;
727+ const events : string [ ] = [ ] ;
728+ let acquireCount = 0 ;
729+ const acquireSessionWriteLock = vi . fn ( async ( ) => {
730+ acquireCount += 1 ;
731+ const lockId = acquireCount ;
732+ events . push ( `acquire-${ lockId } ` ) ;
733+ return {
734+ release : vi . fn ( async ( ) => {
735+ events . push ( `release-${ lockId } ` ) ;
736+ } ) ,
737+ } ;
738+ } ) ;
739+ const firstController = await createEmbeddedAttemptSessionLockController ( {
740+ acquireSessionWriteLock,
741+ lockOptions : { ...lockOptions , sessionFile } ,
742+ } ) ;
743+
744+ await firstController . releaseForSessionIdleWait ( ) ;
745+ await firstController . withSessionWriteLock ( async ( ) => {
746+ events . push ( "first post-wait write" ) ;
747+ } ) ;
748+
749+ const secondController = await createEmbeddedAttemptSessionLockController ( {
750+ acquireSessionWriteLock,
751+ lockOptions : { ...lockOptions , sessionFile } ,
752+ } ) ;
753+ let secondReleasedForPrompt = false ;
754+ await secondController . releaseForPrompt ( ) . then ( ( ) => {
755+ secondReleasedForPrompt = true ;
756+ events . push ( "second released for prompt" ) ;
757+ } ) ;
758+
759+ expect ( secondReleasedForPrompt ) . toBe ( true ) ;
760+ expect ( events ) . toEqual ( [
761+ "acquire-1" ,
762+ "release-1" ,
763+ "acquire-2" ,
764+ "first post-wait write" ,
765+ "release-2" ,
766+ "acquire-3" ,
767+ "release-3" ,
768+ "second released for prompt" ,
769+ ] ) ;
770+ expect ( firstController . hasSessionTakeover ( ) ) . toBe ( false ) ;
771+ expect ( secondController . hasSessionTakeover ( ) ) . toBe ( false ) ;
772+ } ) ;
773+
548774 it ( "rejects external edits interleaved while another controller holds cleanup lock" , async ( ) => {
549775 const sessionFile = await createTempSessionFile ( ) ;
550776 const releases : string [ ] = [ ] ;
@@ -564,6 +790,7 @@ describe("embedded attempt session lock lifecycle", () => {
564790 acquireSessionWriteLock,
565791 lockOptions : { ...lockOptions , sessionFile } ,
566792 } ) ;
793+ resetEmbeddedAttemptSessionFilePromptGuardsForTest ( ) ;
567794 await secondController . releaseForPrompt ( ) ;
568795 const cleanupLock = await secondController . acquireForCleanup ( ) ;
569796
@@ -629,6 +856,7 @@ describe("embedded attempt session lock lifecycle", () => {
629856 } ,
630857 ) ,
631858 ) ;
859+ resetEmbeddedAttemptSessionFilePromptGuardsForTest ( ) ;
632860 await secondController . releaseForPrompt ( ) ;
633861
634862 await expect (
@@ -665,6 +893,7 @@ describe("embedded attempt session lock lifecycle", () => {
665893 await fs . appendFile ( sessionFile , '{"type":"message","id":"same-process"}\n' , "utf8" ) ;
666894 await fs . appendFile ( sessionFile , '{"type":"message","id":"external-interleaved"}\n' , "utf8" ) ;
667895 } ) ;
896+ resetEmbeddedAttemptSessionFilePromptGuardsForTest ( ) ;
668897 await secondController . releaseForPrompt ( ) ;
669898
670899 await expect (
@@ -698,6 +927,7 @@ describe("embedded attempt session lock lifecycle", () => {
698927 acquireSessionWriteLock,
699928 lockOptions : { ...lockOptions , sessionFile } ,
700929 } ) ;
930+ resetEmbeddedAttemptSessionFilePromptGuardsForTest ( ) ;
701931 await secondController . releaseForPrompt ( ) ;
702932
703933 await expect (
@@ -734,6 +964,7 @@ describe("embedded attempt session lock lifecycle", () => {
734964 await secondController . withSessionWriteLock ( async ( ) => {
735965 await fs . appendFile ( sessionFile , '{"type":"message","id":"same-process"}\n' , "utf8" ) ;
736966 } ) ;
967+ resetEmbeddedAttemptSessionFilePromptGuardsForTest ( ) ;
737968 await secondController . releaseForPrompt ( ) ;
738969
739970 await expect (
0 commit comments