11// Coverage for embedded attempt session-file ownership and write locks.
2+ import { appendFileSync } from "node:fs" ;
23import fs from "node:fs/promises" ;
34import os from "node:os" ;
45import path from "node:path" ;
@@ -9,6 +10,7 @@ import {
910 runWithOwnedSessionTranscriptWritePublication ,
1011 withOwnedSessionTranscriptWrites ,
1112} from "../../../config/sessions/transcript-write-context.js" ;
13+ import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js" ;
1214import {
1315 SessionWriteLockStaleError ,
1416 SessionWriteLockTimeoutError ,
@@ -17,6 +19,7 @@ import {
1719 acquireSessionWriteLock ,
1820 resetSessionWriteLockStateForTest ,
1921} from "../../session-write-lock.js" ;
22+ import { SessionManager } from "../../sessions/session-manager.js" ;
2023import {
2124 acquireEmbeddedAttemptSessionFileOwner ,
2225 createEmbeddedAttemptSessionLockController ,
@@ -904,6 +907,273 @@ describe("embedded attempt session lock lifecycle", () => {
904907 expect ( controller . hasSessionTakeover ( ) ) . toBe ( false ) ;
905908 } ) ;
906909
910+ it ( "refreshes the prompt fence after an owned session manager compaction append" , async ( ) => {
911+ const sessionFile = await createTempSessionFile ( ) ;
912+ const release = vi . fn ( async ( ) => { } ) ;
913+ const acquireSessionWriteLockLocal2 = vi . fn ( async ( ) => ( { release } ) ) ;
914+ const controller = await createEmbeddedAttemptSessionLockController ( {
915+ acquireSessionWriteLock : acquireSessionWriteLockLocal2 ,
916+ lockOptions : { ...lockOptions , sessionFile } ,
917+ } ) ;
918+ const sessionManager = guardSessionManager ( SessionManager . open ( sessionFile ) , {
919+ withCompactionPersistence : ( append , validateAppend ) =>
920+ controller . withOwnedSessionFileWrite ( append , validateAppend ) ,
921+ } ) ;
922+ const firstKeptEntryId = sessionManager . appendMessage ( {
923+ role : "user" ,
924+ content : "old question" ,
925+ timestamp : 1 ,
926+ } ) ;
927+ sessionManager . appendMessage ( {
928+ role : "assistant" ,
929+ content : [ { type : "text" , text : "old answer" } ] ,
930+ api : "messages" ,
931+ provider : "openclaw" ,
932+ model : "session-lock-test" ,
933+ usage : {
934+ input : 0 ,
935+ output : 0 ,
936+ cacheRead : 0 ,
937+ cacheWrite : 0 ,
938+ totalTokens : 0 ,
939+ cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 } ,
940+ } ,
941+ stopReason : "stop" ,
942+ timestamp : 2 ,
943+ } ) ;
944+
945+ await controller . releaseForPrompt ( ) ;
946+ sessionManager . appendCompaction ( "threshold summary" , firstKeptEntryId , 160_001 ) ;
947+
948+ await expect ( controller . withSessionWriteLock ( ( ) => "finalize" ) ) . resolves . toBe ( "finalize" ) ;
949+ expect ( controller . hasSessionTakeover ( ) ) . toBe ( false ) ;
950+ } ) ;
951+
952+ it ( "still rejects unowned external compaction appends before the prompt stream lock is reacquired" , async ( ) => {
953+ const sessionFile = await createTempSessionFile ( ) ;
954+ const release = vi . fn ( async ( ) => { } ) ;
955+ const acquireSessionWriteLockLocal1 = vi . fn ( async ( ) => ( { release } ) ) ;
956+ const controller = await createEmbeddedAttemptSessionLockController ( {
957+ acquireSessionWriteLock : acquireSessionWriteLockLocal1 ,
958+ lockOptions : { ...lockOptions , sessionFile } ,
959+ } ) ;
960+
961+ await controller . releaseForPrompt ( ) ;
962+ await fs . appendFile (
963+ sessionFile ,
964+ JSON . stringify ( {
965+ type : "compaction" ,
966+ id : "external-compaction" ,
967+ parentId : "session" ,
968+ timestamp : new Date ( ) . toISOString ( ) ,
969+ summary : "external summary" ,
970+ firstKeptEntryId : "session" ,
971+ tokensBefore : 160_001 ,
972+ } ) + "\n" ,
973+ "utf8" ,
974+ ) ;
975+
976+ await expect ( controller . withSessionWriteLock ( ( ) => "finalize" ) ) . rejects . toBeInstanceOf (
977+ EmbeddedAttemptSessionTakeoverError ,
978+ ) ;
979+ expect ( controller . hasSessionTakeover ( ) ) . toBe ( true ) ;
980+ } ) ;
981+
982+ it ( "still rejects an external edit that happens before an owned session manager compaction append" , async ( ) => {
983+ const sessionFile = await createTempSessionFile ( ) ;
984+ const release = vi . fn ( async ( ) => { } ) ;
985+ const acquireSessionWriteLockLocal0 = vi . fn ( async ( ) => ( { release } ) ) ;
986+ const controller = await createEmbeddedAttemptSessionLockController ( {
987+ acquireSessionWriteLock : acquireSessionWriteLockLocal0 ,
988+ lockOptions : { ...lockOptions , sessionFile } ,
989+ } ) ;
990+ const sessionManager = guardSessionManager ( SessionManager . open ( sessionFile ) , {
991+ withCompactionPersistence : ( append , validateAppend ) =>
992+ controller . withOwnedSessionFileWrite ( append , validateAppend ) ,
993+ } ) ;
994+ const firstKeptEntryId = sessionManager . appendMessage ( {
995+ role : "user" ,
996+ content : "old question" ,
997+ timestamp : 1 ,
998+ } ) ;
999+ sessionManager . appendMessage ( {
1000+ role : "assistant" ,
1001+ content : [ { type : "text" , text : "old answer" } ] ,
1002+ api : "messages" ,
1003+ provider : "openclaw" ,
1004+ model : "session-lock-test" ,
1005+ usage : {
1006+ input : 0 ,
1007+ output : 0 ,
1008+ cacheRead : 0 ,
1009+ cacheWrite : 0 ,
1010+ totalTokens : 0 ,
1011+ cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 } ,
1012+ } ,
1013+ stopReason : "stop" ,
1014+ timestamp : 2 ,
1015+ } ) ;
1016+
1017+ await controller . releaseForPrompt ( ) ;
1018+ await fs . appendFile ( sessionFile , '{"type":"message","id":"external-edit"}\n' , "utf8" ) ;
1019+ sessionManager . appendCompaction ( "threshold summary" , firstKeptEntryId , 160_001 ) ;
1020+
1021+ await expect ( controller . withSessionWriteLock ( ( ) => "finalize" ) ) . rejects . toBeInstanceOf (
1022+ EmbeddedAttemptSessionTakeoverError ,
1023+ ) ;
1024+ expect ( controller . hasSessionTakeover ( ) ) . toBe ( true ) ;
1025+ } ) ;
1026+
1027+ it ( "still rejects an external edit interleaved inside an owned session manager compaction append" , async ( ) => {
1028+ const sessionFile = await createTempSessionFile ( ) ;
1029+ const release = vi . fn ( async ( ) => { } ) ;
1030+ const acquireSessionWriteLockLocal30 = vi . fn ( async ( ) => ( { release } ) ) ;
1031+ const controller = await createEmbeddedAttemptSessionLockController ( {
1032+ acquireSessionWriteLock : acquireSessionWriteLockLocal30 ,
1033+ lockOptions : { ...lockOptions , sessionFile } ,
1034+ } ) ;
1035+ const sessionManager = guardSessionManager ( SessionManager . open ( sessionFile ) , {
1036+ withCompactionPersistence : ( append , validateAppend ) =>
1037+ controller . withOwnedSessionFileWrite ( ( ) => {
1038+ appendFileSync ( sessionFile , '{"type":"message","id":"external-edit"}\n' , "utf8" ) ;
1039+ return append ( ) ;
1040+ } , validateAppend ) ,
1041+ } ) ;
1042+ const firstKeptEntryId = sessionManager . appendMessage ( {
1043+ role : "user" ,
1044+ content : "old question" ,
1045+ timestamp : 1 ,
1046+ } ) ;
1047+ sessionManager . appendMessage ( {
1048+ role : "assistant" ,
1049+ content : [ { type : "text" , text : "old answer" } ] ,
1050+ api : "messages" ,
1051+ provider : "openclaw" ,
1052+ model : "session-lock-test" ,
1053+ usage : {
1054+ input : 0 ,
1055+ output : 0 ,
1056+ cacheRead : 0 ,
1057+ cacheWrite : 0 ,
1058+ totalTokens : 0 ,
1059+ cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 } ,
1060+ } ,
1061+ stopReason : "stop" ,
1062+ timestamp : 2 ,
1063+ } ) ;
1064+
1065+ await controller . releaseForPrompt ( ) ;
1066+ sessionManager . appendCompaction ( "threshold summary" , firstKeptEntryId , 160_001 ) ;
1067+
1068+ await expect ( controller . withSessionWriteLock ( ( ) => "finalize" ) ) . rejects . toBeInstanceOf (
1069+ EmbeddedAttemptSessionTakeoverError ,
1070+ ) ;
1071+ expect ( controller . hasSessionTakeover ( ) ) . toBe ( true ) ;
1072+ } ) ;
1073+
1074+ it ( "allows owned session manager compaction after a later controller advances the prompt fence" , async ( ) => {
1075+ const sessionFile = await createTempSessionFile ( ) ;
1076+ const firstController = await createEmbeddedAttemptSessionLockController ( {
1077+ acquireSessionWriteLock : vi . fn ( async ( ) => ( { release : vi . fn ( async ( ) => { } ) } ) ) ,
1078+ lockOptions : { ...lockOptions , sessionFile } ,
1079+ } ) ;
1080+ await firstController . releaseForPrompt ( ) ;
1081+ await firstController . dispose ( ) ;
1082+
1083+ const release = vi . fn ( async ( ) => { } ) ;
1084+ const acquireSessionWriteLockLocal29 = vi . fn ( async ( ) => ( { release } ) ) ;
1085+ const secondController = await createEmbeddedAttemptSessionLockController ( {
1086+ acquireSessionWriteLock : acquireSessionWriteLockLocal29 ,
1087+ lockOptions : { ...lockOptions , sessionFile } ,
1088+ } ) ;
1089+ const sessionManager = guardSessionManager ( SessionManager . open ( sessionFile ) , {
1090+ withCompactionPersistence : ( append , validateAppend ) =>
1091+ secondController . withOwnedSessionFileWrite ( append , validateAppend ) ,
1092+ } ) ;
1093+ const firstKeptEntryId = await secondController . withSessionWriteLock ( ( ) => {
1094+ const entryId = sessionManager . appendMessage ( {
1095+ role : "user" ,
1096+ content : "new question" ,
1097+ timestamp : 1 ,
1098+ } ) ;
1099+ sessionManager . appendMessage ( {
1100+ role : "assistant" ,
1101+ content : [ { type : "text" , text : "new answer" } ] ,
1102+ api : "messages" ,
1103+ provider : "openclaw" ,
1104+ model : "session-lock-test" ,
1105+ usage : {
1106+ input : 0 ,
1107+ output : 0 ,
1108+ cacheRead : 0 ,
1109+ cacheWrite : 0 ,
1110+ totalTokens : 0 ,
1111+ cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 } ,
1112+ } ,
1113+ stopReason : "stop" ,
1114+ timestamp : 2 ,
1115+ } ) ;
1116+ return entryId ;
1117+ } ) ;
1118+
1119+ await secondController . releaseForPrompt ( ) ;
1120+ sessionManager . appendCompaction ( "threshold summary" , firstKeptEntryId , 160_001 ) ;
1121+
1122+ await expect ( secondController . withSessionWriteLock ( ( ) => "finalize" ) ) . resolves . toBe ( "finalize" ) ;
1123+ expect ( secondController . hasSessionTakeover ( ) ) . toBe ( false ) ;
1124+ } ) ;
1125+
1126+ it ( "allows owned session manager compaction after another controller publishes an owned write" , async ( ) => {
1127+ const sessionFile = await createTempSessionFile ( ) ;
1128+ const firstController = await createEmbeddedAttemptSessionLockController ( {
1129+ acquireSessionWriteLock : vi . fn ( async ( ) => ( { release : vi . fn ( async ( ) => { } ) } ) ) ,
1130+ lockOptions : { ...lockOptions , sessionFile } ,
1131+ } ) ;
1132+ const sessionManager = guardSessionManager ( SessionManager . open ( sessionFile ) , {
1133+ withCompactionPersistence : ( append , validateAppend ) =>
1134+ firstController . withOwnedSessionFileWrite ( append , validateAppend ) ,
1135+ } ) ;
1136+ const firstKeptEntryId = sessionManager . appendMessage ( {
1137+ role : "user" ,
1138+ content : "old question" ,
1139+ timestamp : 1 ,
1140+ } ) ;
1141+ sessionManager . appendMessage ( {
1142+ role : "assistant" ,
1143+ content : [ { type : "text" , text : "old answer" } ] ,
1144+ api : "messages" ,
1145+ provider : "openclaw" ,
1146+ model : "session-lock-test" ,
1147+ usage : {
1148+ input : 0 ,
1149+ output : 0 ,
1150+ cacheRead : 0 ,
1151+ cacheWrite : 0 ,
1152+ totalTokens : 0 ,
1153+ cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 } ,
1154+ } ,
1155+ stopReason : "stop" ,
1156+ timestamp : 2 ,
1157+ } ) ;
1158+ await firstController . releaseForPrompt ( ) ;
1159+
1160+ const secondController = await createEmbeddedAttemptSessionLockController ( {
1161+ acquireSessionWriteLock : vi . fn ( async ( ) => ( { release : vi . fn ( async ( ) => { } ) } ) ) ,
1162+ lockOptions : { ...lockOptions , sessionFile } ,
1163+ } ) ;
1164+ await secondController . releaseForPrompt ( ) ;
1165+ await secondController . withSessionWriteLock (
1166+ async ( ) => {
1167+ await fs . appendFile ( sessionFile , '{"type":"message","id":"owned-other"}\n' , "utf8" ) ;
1168+ } ,
1169+ { publishOwnedWrite : true } ,
1170+ ) ;
1171+ sessionManager . appendCompaction ( "threshold summary" , firstKeptEntryId , 160_001 ) ;
1172+
1173+ await expect ( firstController . withSessionWriteLock ( ( ) => "finalize" ) ) . resolves . toBe ( "finalize" ) ;
1174+ expect ( firstController . hasSessionTakeover ( ) ) . toBe ( false ) ;
1175+ } ) ;
1176+
9071177 it ( "allows post-prompt writes after the prompt context publishes an owned transcript write" , async ( ) => {
9081178 const sessionFile = await createTempSessionFile ( ) ;
9091179 const releases : string [ ] = [ ] ;
0 commit comments