@@ -5,6 +5,7 @@ import type { HistoryItem, ExtensionMessage } from "@roo-code/types"
55import { TelemetryService } from "@roo-code/telemetry"
66
77import { ContextProxy } from "../../config/ContextProxy"
8+ import { taskHistoryLock } from "../../task-persistence/TaskHistoryLock"
89import { ClineProvider } from "../ClineProvider"
910
1011// Mock setup
@@ -240,6 +241,7 @@ vi.mock("@roo-code/cloud", () => ({
240241 getOrganizationMemberships : vi . fn ( ) . mockResolvedValue ( [ ] ) ,
241242 getUserSettings : vi . fn ( ) . mockReturnValue ( null ) ,
242243 isTaskSyncEnabled : vi . fn ( ) . mockReturnValue ( false ) ,
244+ off : vi . fn ( ) ,
243245 }
244246 } ,
245247 } ,
@@ -260,6 +262,7 @@ describe("ClineProvider Task History Synchronization", () => {
260262
261263 beforeEach ( async ( ) => {
262264 vi . clearAllMocks ( )
265+ taskHistoryLock . reset ( )
263266
264267 if ( ! TelemetryService . hasInstance ( ) ) {
265268 TelemetryService . createInstance ( [ ] )
@@ -779,5 +782,147 @@ describe("ClineProvider Task History Synchronization", () => {
779782 // The second write (tokensIn: 222) should be the last one since writes are serialized
780783 expect ( item ! . tokensIn ) . toBe ( 222 )
781784 } )
785+
786+ it ( "serializes concurrent updateTaskHistory from two parallel instances" , async ( ) => {
787+ const provider2 = new ClineProvider (
788+ mockContext ,
789+ mockOutputChannel ,
790+ "sidebar" ,
791+ new ContextProxy ( mockContext ) ,
792+ )
793+ await provider2 . taskHistoryStore . initialized
794+
795+ let inCriticalSection = 0
796+ let maxConcurrentMutations = 0
797+ const entered : string [ ] = [ ]
798+
799+ const makeSerializedUpsert = ( instanceName : string ) =>
800+ vi . fn ( async ( item : HistoryItem ) => {
801+ inCriticalSection ++
802+ maxConcurrentMutations = Math . max ( maxConcurrentMutations , inCriticalSection )
803+ entered . push ( `${ instanceName } :${ item . id } ` )
804+ await new Promise ( ( resolve ) => setTimeout ( resolve , 5 ) )
805+ inCriticalSection --
806+ return [ item ]
807+ } )
808+
809+ const provider1Upsert = makeSerializedUpsert ( "provider1" )
810+ const provider2Upsert = makeSerializedUpsert ( "provider2" )
811+ vi . spyOn ( provider . taskHistoryStore , "upsert" ) . mockImplementation ( provider1Upsert )
812+ vi . spyOn ( provider2 . taskHistoryStore , "upsert" ) . mockImplementation ( provider2Upsert )
813+
814+ try {
815+ const provider1Item = createHistoryItem ( { id : "parallel-provider-1" , task : "Provider 1" } )
816+ const provider2Item = createHistoryItem ( { id : "parallel-provider-2" , task : "Provider 2" } )
817+
818+ await Promise . all ( [
819+ provider . updateTaskHistory ( provider1Item , { broadcast : false } ) ,
820+ provider2 . updateTaskHistory ( provider2Item , { broadcast : false } ) ,
821+ ] )
822+
823+ expect ( provider1Upsert ) . toHaveBeenCalledTimes ( 1 )
824+ expect ( provider2Upsert ) . toHaveBeenCalledTimes ( 1 )
825+ expect ( entered ) . toHaveLength ( 2 )
826+ expect ( maxConcurrentMutations ) . toBe ( 1 )
827+ } finally {
828+ await provider2 . dispose ( )
829+ }
830+ } )
831+
832+ it ( "serializes 5+ concurrent updateTaskHistory calls from different tabs" , async ( ) => {
833+ const providers = [ provider ]
834+
835+ for ( let i = 1 ; i < 5 ; i ++ ) {
836+ const nextProvider = new ClineProvider (
837+ mockContext ,
838+ mockOutputChannel ,
839+ "sidebar" ,
840+ new ContextProxy ( mockContext ) ,
841+ )
842+ await nextProvider . taskHistoryStore . initialized
843+ providers . push ( nextProvider )
844+ }
845+
846+ let inCriticalSection = 0
847+ let maxConcurrentMutations = 0
848+ const entered : string [ ] = [ ]
849+
850+ try {
851+ providers . forEach ( ( currentProvider , providerIndex ) => {
852+ vi . spyOn ( currentProvider . taskHistoryStore , "upsert" ) . mockImplementation (
853+ async ( item : HistoryItem ) => {
854+ inCriticalSection ++
855+ maxConcurrentMutations = Math . max ( maxConcurrentMutations , inCriticalSection )
856+ entered . push ( `provider-${ providerIndex } :${ item . id } ` )
857+ await new Promise ( ( resolve ) => setTimeout ( resolve , 5 ) )
858+ inCriticalSection --
859+ return [ item ]
860+ } ,
861+ )
862+ } )
863+
864+ await Promise . all (
865+ providers . map ( ( currentProvider , index ) =>
866+ currentProvider . updateTaskHistory (
867+ createHistoryItem ( { id : `parallel-tab-${ index } ` , task : `Parallel Tab ${ index } ` } ) ,
868+ { broadcast : false } ,
869+ ) ,
870+ ) ,
871+ )
872+
873+ expect ( entered ) . toHaveLength ( 5 )
874+ expect ( maxConcurrentMutations ) . toBe ( 1 )
875+ } finally {
876+ for ( const currentProvider of providers . slice ( 1 ) ) {
877+ await currentProvider . dispose ( )
878+ }
879+ }
880+ } )
881+
882+ it ( "serializes concurrent updateTaskHistory and deleteTaskFromState across tabs" , async ( ) => {
883+ const provider2 = new ClineProvider (
884+ mockContext ,
885+ mockOutputChannel ,
886+ "sidebar" ,
887+ new ContextProxy ( mockContext ) ,
888+ )
889+ await provider2 . taskHistoryStore . initialized
890+
891+ let inCriticalSection = 0
892+ let maxConcurrentMutations = 0
893+ const entered : string [ ] = [ ]
894+
895+ vi . spyOn ( provider . taskHistoryStore , "upsert" ) . mockImplementation ( async ( item : HistoryItem ) => {
896+ inCriticalSection ++
897+ maxConcurrentMutations = Math . max ( maxConcurrentMutations , inCriticalSection )
898+ entered . push ( `update:${ item . id } ` )
899+ await new Promise ( ( resolve ) => setTimeout ( resolve , 5 ) )
900+ inCriticalSection --
901+ return [ item ]
902+ } )
903+ vi . spyOn ( provider2 . taskHistoryStore , "delete" ) . mockImplementation ( async ( id : string ) => {
904+ inCriticalSection ++
905+ maxConcurrentMutations = Math . max ( maxConcurrentMutations , inCriticalSection )
906+ entered . push ( `delete:${ id } ` )
907+ await new Promise ( ( resolve ) => setTimeout ( resolve , 5 ) )
908+ inCriticalSection --
909+ } )
910+ vi . spyOn ( provider2 , "postStateToWebview" ) . mockResolvedValue ( undefined )
911+
912+ try {
913+ await Promise . all ( [
914+ provider . updateTaskHistory ( createHistoryItem ( { id : "cross-update" , task : "Cross update" } ) , {
915+ broadcast : false ,
916+ } ) ,
917+ provider2 . deleteTaskFromState ( "cross-delete" ) ,
918+ ] )
919+
920+ expect ( entered ) . toEqual ( expect . arrayContaining ( [ "update:cross-update" , "delete:cross-delete" ] ) )
921+ expect ( entered ) . toHaveLength ( 2 )
922+ expect ( maxConcurrentMutations ) . toBe ( 1 )
923+ } finally {
924+ await provider2 . dispose ( )
925+ }
926+ } )
782927 } )
783928} )
0 commit comments