@@ -88,6 +88,7 @@ import { createRunSegment } from '@/lib/mothership/async-runs/repository'
8888import { admitChatTurn } from '@/lib/mothership/chat/application/admit-turn'
8989import { deleteCopilotChat , softDeleteMothershipChat } from '@/lib/mothership/chat/deletion'
9090import { chatPubSub } from '@/lib/mothership/chat-status'
91+ import { abortRun , steerRun } from '@/lib/mothership/request/application/controls'
9192import { claimRunController } from '@/lib/mothership/request/lifecycle/controller-ownership'
9293import {
9394 acquirePendingChatStream ,
@@ -966,6 +967,74 @@ describe.runIf(Boolean(redisUrl))('removing a run’s chat, owner or access stop
966967 expect ( await runControl ( run ) ) . toEqual ( STOPPED )
967968 } )
968969
970+ it ( 'lets a blocked owner Stop their run, and neither steer nor send, nor anyone else Stop it' , async ( ) => {
971+ const ownerId = await seedUser ( )
972+ const memberId = await seedUser ( )
973+ const workspaceId = await seedWorkspace ( ownerId , [ memberId ] )
974+ const run = await seedRun ( { userId : ownerId , workspaceId } )
975+ await db . update ( user ) . set ( { banned : true } ) . where ( eq ( user . id , ownerId ) )
976+ const owner = createSessionPrincipal ( { userId : ownerId , sessionId : generateId ( ) } )
977+ const input = { streamId : run . streamId , chatId : run . chatId , workspaceId }
978+
979+ await expect (
980+ steerRun . execute ( {
981+ principal : owner ,
982+ input : { ...input , steeringId : generateId ( ) , content : 'Keep going' } ,
983+ } )
984+ ) . rejects . toMatchObject ( { code : 'forbidden' } )
985+ const sendStreamId = generateId ( )
986+ expect ( await acquirePendingChatStream ( run . chatId , sendStreamId , 0 ) ) . toBe ( true )
987+ const lease = getLocalChatStreamLease ( run . chatId , sendStreamId )
988+ if ( ! lease ) throw new Error ( 'The send needs its chat lease' )
989+ const sendClaim = { normalizedKey : `run-removal:${ generateId ( ) } ` , claimToken : generateId ( ) }
990+ await db
991+ . insert ( idempotencyKey )
992+ . values ( { key : sendClaim . normalizedKey , result : { claimToken : sendClaim . claimToken } } )
993+ await expect (
994+ admitChatTurn
995+ . execute ( {
996+ principal : owner ,
997+ input : {
998+ chatId : run . chatId ,
999+ runId : generateId ( ) ,
1000+ executionId : generateId ( ) ,
1001+ requestId : generateId ( ) ,
1002+ message : { id : sendStreamId , content : 'Start over' } ,
1003+ recovery : {
1004+ kind : 'interactive_stream' ,
1005+ goRoute : '/api/mothership' ,
1006+ clientToolPickupExpected : false ,
1007+ request : {
1008+ userId : ownerId ,
1009+ chatId : run . chatId ,
1010+ messageId : sendStreamId ,
1011+ workspaceId,
1012+ message : 'Start over' ,
1013+ } ,
1014+ } ,
1015+ lease,
1016+ sendClaim,
1017+ notifyWorkspaceStatus : false ,
1018+ } ,
1019+ } )
1020+ . finally ( ( ) => releasePendingChatStream ( run . chatId , sendStreamId , lease ) )
1021+ ) . rejects . toMatchObject ( { code : 'forbidden' } )
1022+ await expect (
1023+ abortRun . execute ( {
1024+ principal : createSessionPrincipal ( { userId : memberId , sessionId : generateId ( ) } ) ,
1025+ input,
1026+ } )
1027+ ) . rejects . toMatchObject ( { code : 'not_found' } )
1028+ expect ( await hasStop ( run ) ) . toBe ( false )
1029+ expect ( pushedStops ( ) ) . toEqual ( [ ] )
1030+
1031+ await expect ( abortRun . execute ( { principal : owner , input } ) ) . resolves . toMatchObject ( {
1032+ aborted : true ,
1033+ } )
1034+ expect ( await hasStop ( run ) ) . toBe ( true )
1035+ expect ( pushedStops ( ) ) . toEqual ( [ run . streamId ] )
1036+ } )
1037+
9691038 /**
9701039 * Admission and deletion both write the chat row, so they serialize on its lock; holding it
9711040 * queues them in a chosen order, then releases them together.
0 commit comments