@@ -44,6 +44,7 @@ import * as Stream from "effect/Stream";
4444import * as TestClock from "effect/testing/TestClock" ;
4545import * as SqlClient from "effect/sql/SqlClient" ;
4646
47+ import { createAttachmentId } from "../attachmentStore.ts" ;
4748import * as CheckpointStore from "../checkpointing/CheckpointStore.ts" ;
4849import * as GitWorkflow from "../git/GitWorkflowService.ts" ;
4950import * as ServerConfig from "../config.ts" ;
@@ -719,6 +720,181 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i
719720 ) ;
720721 } ) ,
721722 ) ;
723+
724+ it . effect . each ( [ "fork" , "delete" ] as const ) (
725+ "preserves page ownership when %s commits before a concurrent fork or deletion" ,
726+ ( first ) =>
727+ Effect . gen ( function * ( ) {
728+ const orchestrator = yield * Orchestrator . OrchestratorV2 ;
729+ const eventSink = yield * EventSink . EventSinkV2 ;
730+ const executor = yield * ThreadCommandExecutor . ThreadCommandExecutor ;
731+ const now = yield * DateTime . now ;
732+ const sourceThreadId = ThreadId . make ( `fork-delete-source:${ first } ` ) ;
733+ const targetThreadId = ThreadId . make ( `fork-delete-target:${ first } ` ) ;
734+ const runId = RunId . make ( `fork-delete-run:${ first } ` ) ;
735+ const forkCommandId = CommandId . make ( `fork-delete-fork:${ first } ` ) ;
736+ const deleteCommandId = CommandId . make ( `fork-delete-delete:${ first } ` ) ;
737+ const attachmentId = createAttachmentId ( sourceThreadId , "html" ) ! ;
738+ yield * orchestrator . dispatch ( {
739+ type : "thread.create" ,
740+ commandId : CommandId . make ( `fork-delete-create:${ first } ` ) ,
741+ threadId : sourceThreadId ,
742+ projectId : ProjectId . make ( `fork-delete-project:${ first } ` ) ,
743+ title : "Page source" ,
744+ modelSelection,
745+ runtimeMode : "full-access" ,
746+ interactionMode : "default" ,
747+ branch : null ,
748+ worktreePath : null ,
749+ createdBy : "user" ,
750+ creationSource : "web" ,
751+ } ) ;
752+ yield * eventSink . write ( {
753+ events : [
754+ {
755+ id : EventId . make ( `fork-delete-run:${ first } ` ) ,
756+ type : "run.created" ,
757+ threadId : sourceThreadId ,
758+ runId,
759+ occurredAt : now ,
760+ payload : {
761+ id : runId ,
762+ threadId : sourceThreadId ,
763+ ordinal : 1 ,
764+ providerInstanceId : modelSelection . instanceId ,
765+ modelSelection,
766+ providerThreadId : null ,
767+ userMessageId : MessageId . make ( `fork-delete-message:${ first } ` ) ,
768+ rootNodeId : null ,
769+ activeAttemptId : null ,
770+ status : "completed" ,
771+ requestedAt : now ,
772+ startedAt : now ,
773+ completedAt : now ,
774+ checkpointId : null ,
775+ contextHandoffId : null ,
776+ } ,
777+ } ,
778+ {
779+ id : EventId . make ( `fork-delete-page:${ first } ` ) ,
780+ type : "turn-item.updated" ,
781+ threadId : sourceThreadId ,
782+ runId,
783+ occurredAt : now ,
784+ payload : {
785+ id : TurnItemId . make ( `fork-delete-page:${ first } ` ) ,
786+ threadId : sourceThreadId ,
787+ runId,
788+ nodeId : null ,
789+ providerThreadId : null ,
790+ providerTurnId : null ,
791+ nativeItemRef : null ,
792+ parentItemId : null ,
793+ ordinal : 1 ,
794+ status : "completed" ,
795+ title : "Shared page" ,
796+ startedAt : now ,
797+ completedAt : now ,
798+ updatedAt : now ,
799+ type : "dynamic_tool" ,
800+ toolName : "mcp__t3-code__html_render" ,
801+ input : { } ,
802+ output : { htmlRender : { attachmentId, title : "Shared page" , height : 300 } } ,
803+ } ,
804+ } ,
805+ ] ,
806+ } ) ;
807+ const firstReady = yield * Deferred . make < void > ( ) ;
808+ const releaseFirst = yield * Deferred . make < void > ( ) ;
809+ const secondQueued = yield * Deferred . make < void > ( ) ;
810+ const cleanupIds : Array < string > = [ ] ;
811+ const commitCommand = eventSink . commitCommand ;
812+ const commitSpy = vi . spyOn ( eventSink , "commitCommand" ) . mockImplementation ( ( input ) => {
813+ if ( input . commandId === deleteCommandId ) {
814+ for ( const effect of input . effects ?? [ ] ) {
815+ if ( effect . request . type === "attachment.cleanup" )
816+ cleanupIds . push ( ...effect . request . attachmentIds ) ;
817+ }
818+ }
819+ return input . commandId === ( first === "fork" ? forkCommandId : deleteCommandId )
820+ ? Deferred . succeed ( firstReady , undefined ) . pipe (
821+ Effect . andThen ( Deferred . await ( releaseFirst ) ) ,
822+ Effect . andThen ( commitCommand ( input ) ) ,
823+ )
824+ : commitCommand ( input ) ;
825+ } ) ;
826+ const withDeletionLock = executor . withDeletionLock ;
827+ let lockRequests = 0 ;
828+ const observeLock : ThreadCommandExecutor . ThreadCommandExecutor [ "Service" ] [ "withDeletionLock" ] =
829+ ( effect ) =>
830+ ++ lockRequests === 2
831+ ? Deferred . succeed ( secondQueued , undefined ) . pipe (
832+ Effect . andThen ( withDeletionLock ( effect ) ) ,
833+ )
834+ : withDeletionLock ( effect ) ;
835+ const lockSpy = vi . spyOn ( executor , "withDeletionLock" ) . mockImplementation ( observeLock ) ;
836+ yield * Effect . gen ( function * ( ) {
837+ const fork = orchestrator
838+ . dispatch ( {
839+ type : "thread.fork" ,
840+ commandId : forkCommandId ,
841+ sourceThreadId,
842+ targetThreadId,
843+ sourcePoint : { type : "run" , runId } ,
844+ createdBy : "user" ,
845+ creationSource : "web" ,
846+ } )
847+ . pipe ( Effect . result ) ;
848+ const deletion = orchestrator
849+ . dispatch ( {
850+ type : "thread.delete" ,
851+ commandId : deleteCommandId ,
852+ threadId : sourceThreadId ,
853+ } )
854+ . pipe ( Effect . result ) ;
855+ const firstFiber = yield * ( first === "fork" ? fork : deletion ) . pipe (
856+ Effect . forkChild ( { startImmediately : true } ) ,
857+ ) ;
858+ yield * Effect . raceFirst (
859+ Deferred . await ( firstReady ) ,
860+ Fiber . join ( firstFiber ) . pipe ( Effect . andThen ( Effect . die ( "Missing commit barrier" ) ) ) ,
861+ ) ;
862+ const secondFiber = yield * ( first === "fork" ? deletion : fork ) . pipe (
863+ Effect . forkChild ( { startImmediately : true } ) ,
864+ ) ;
865+ yield * Effect . raceFirst ( Deferred . await ( secondQueued ) , Fiber . await ( secondFiber ) ) ;
866+ yield * Deferred . succeed ( releaseFirst , undefined ) ;
867+ const firstResult = yield * Fiber . join ( firstFiber ) ;
868+ const secondResult = yield * Fiber . join ( secondFiber ) ;
869+ assert . equal ( firstResult . _tag , "Success" ) ;
870+ if ( first === "fork" ) {
871+ assert . equal ( secondResult . _tag , "Success" ) ;
872+ assert . deepEqual ( cleanupIds , [ ] ) ;
873+ const forkProjection = yield * orchestrator . getThreadProjection ( targetThreadId ) ;
874+ assert . isNull ( forkProjection . thread . deletedAt ) ;
875+ assert . isTrue (
876+ forkProjection . visibleTurnItems . some (
877+ ( row ) => row . item . id === TurnItemId . make ( `fork-delete-page:${ first } ` ) ,
878+ ) ,
879+ ) ;
880+ } else {
881+ assert . equal ( secondResult . _tag , "Failure" ) ;
882+ assert . deepEqual ( cleanupIds , [ attachmentId ] ) ;
883+ const target = yield * orchestrator
884+ . getThreadProjection ( targetThreadId )
885+ . pipe ( Effect . result ) ;
886+ assert . equal ( target . _tag , "Failure" ) ;
887+ }
888+ } ) . pipe (
889+ Effect . ensuring (
890+ Effect . sync ( ( ) => {
891+ commitSpy . mockRestore ( ) ;
892+ lockSpy . mockRestore ( ) ;
893+ } ) ,
894+ ) ,
895+ ) ;
896+ } ) ,
897+ ) ;
722898} ) ;
723899
724900const layerSharedApplicationDataPlaneTest = Layer . mergeAll (
0 commit comments