@@ -66,6 +66,7 @@ import { NextRequest } from 'next/server'
6666import { closeRedisConnection , getRedisClient } from '@/lib/core/config/redis'
6767import { OrchestrationError } from '@/lib/core/orchestration/types'
6868import { SIM_TOOL_EXECUTION_VERSION } from '@/lib/mothership/async-runs/lifecycle'
69+ import { createRunSegment } from '@/lib/mothership/async-runs/repository'
6970import { admitChatTurn } from '@/lib/mothership/chat/application/admit-turn'
7071import { deleteCopilotChat , softDeleteMothershipChat } from '@/lib/mothership/chat/deletion'
7172import { chatPubSub } from '@/lib/mothership/chat-status'
@@ -203,6 +204,37 @@ async function seedRun(
203204 return { userId : owner . userId , chatId, streamId, runId, ...scope }
204205}
205206
207+ /** A chat with no run yet, as a task wake, inbox email, API or Slack turn finds it. */
208+ async function seedChat ( userId : string , workspaceId : string ) {
209+ const chatId = generateId ( )
210+ await db . insert ( copilotChats ) . values ( { id : chatId , userId, workspaceId, type : 'mothership' } )
211+ return chatId
212+ }
213+
214+ /** Admits a headless turn's run the way those surfaces do, without the chat view's admission. */
215+ async function admitHeadlessRun ( userId : string , chatId : string , workspaceId : string ) {
216+ const streamId = generateId ( )
217+ testStreams . add ( streamId )
218+ const run = await createRunSegment ( {
219+ id : generateId ( ) ,
220+ executionId : generateId ( ) ,
221+ chatId,
222+ userId,
223+ workspaceId,
224+ streamId,
225+ requestContext : { source : 'headless_lifecycle' } ,
226+ } )
227+ return { run, seeded : { userId, chatId, streamId, runId : run . id , workspaceId } }
228+ }
229+
230+ async function toolAdmissionClosedAt ( run : SeededRun ) {
231+ const [ row ] = await db
232+ . select ( { closedAt : copilotRuns . toolAdmissionClosedAt } )
233+ . from ( copilotRuns )
234+ . where ( eq ( copilotRuns . id , run . runId ) )
235+ return row ?. closedAt ?? null
236+ }
237+
206238/** What the worker's heartbeat reads, through the outbound control transport. */
207239async function runControl ( run : SeededRun ) {
208240 const result = await executeSimControl ( {
@@ -294,6 +326,23 @@ async function whileHolding<T>(chatId: string, run: (holder: number) => Promise<
294326 }
295327}
296328
329+ /** Queues admission and deletion behind a held chat row in the order `first` names. */
330+ async function queueOnChat < A , D > (
331+ chatId : string ,
332+ first : 'admission' | 'deletion' ,
333+ admit : ( ) => Promise < A > ,
334+ remove : ( ) => Promise < D >
335+ ) : Promise < { admission : Promise < A > ; deletion : Promise < D > } > {
336+ return whileHolding ( chatId , async ( holder ) => {
337+ const admission = first === 'admission' ? admit ( ) : undefined
338+ const deletion = admission ? undefined : remove ( )
339+ const waiter = await lockWaiterBehind ( holder )
340+ const queued = { admission : admission ?? admit ( ) , deletion : deletion ?? remove ( ) }
341+ await lockWaiterBehind ( holder , waiter )
342+ return queued
343+ } )
344+ }
345+
297346const STOPPED = { status : 200 , body : { stopped : true } }
298347const LIVE = { status : 200 , body : { stopped : false } }
299348
@@ -674,12 +723,9 @@ describe.runIf(Boolean(redisUrl))('removing a run’s chat, owner or access stop
674723 async ( first ) => {
675724 const ownerId = await seedUser ( )
676725 const workspaceId = await seedWorkspace ( ownerId )
677- const chatId = generateId ( )
726+ const chatId = await seedChat ( ownerId , workspaceId )
678727 const streamId = generateId ( )
679728 testStreams . add ( streamId )
680- await db
681- . insert ( copilotChats )
682- . values ( { id : chatId , userId : ownerId , workspaceId, type : 'mothership' } )
683729 const sendClaim = { normalizedKey : `run-removal:${ generateId ( ) } ` , claimToken : generateId ( ) }
684730 await db
685731 . insert ( idempotencyKey )
@@ -725,20 +771,7 @@ describe.runIf(Boolean(redisUrl))('removing a run’s chat, owner or access stop
725771 . finally ( ( ) => releasePendingChatStream ( chatId , streamId , lease ) )
726772 const remove = ( ) => softDeleteMothershipChat ( { chatId, userId : ownerId } )
727773
728- const { admission, deletion } = await whileHolding ( chatId , async ( holder ) => {
729- if ( first === 'admission' ) {
730- const admission = admit ( )
731- const waiter = await lockWaiterBehind ( holder )
732- const deletion = remove ( )
733- await lockWaiterBehind ( holder , waiter )
734- return { admission, deletion }
735- }
736- const deletion = remove ( )
737- const waiter = await lockWaiterBehind ( holder )
738- const admission = admit ( )
739- await lockWaiterBehind ( holder , waiter )
740- return { admission, deletion }
741- } )
774+ const { admission, deletion } = await queueOnChat ( chatId , first , admit , remove )
742775
743776 const admitted = await admission
744777 expect ( await deletion ) . not . toBeNull ( )
@@ -751,4 +784,61 @@ describe.runIf(Boolean(redisUrl))('removing a run’s chat, owner or access stop
751784 expect ( await runControl ( run ) ) . toEqual ( STOPPED )
752785 }
753786 )
787+
788+ it ( 'admits a headless run of a live chat as live' , async ( ) => {
789+ const ownerId = await seedUser ( )
790+ const workspaceId = await seedWorkspace ( ownerId )
791+
792+ const { run, seeded } = await admitHeadlessRun (
793+ ownerId ,
794+ await seedChat ( ownerId , workspaceId ) ,
795+ workspaceId
796+ )
797+
798+ expect ( run ) . toMatchObject ( { status : 'active' , toolAdmissionClosedAt : null , completedAt : null } )
799+ expect ( await runControl ( seeded ) ) . toEqual ( LIVE )
800+ } )
801+
802+ it ( 'admits a headless run of a chat deleted before it as already stopped' , async ( ) => {
803+ const ownerId = await seedUser ( )
804+ const workspaceId = await seedWorkspace ( ownerId )
805+ const chatId = await seedChat ( ownerId , workspaceId )
806+ expect ( await softDeleteMothershipChat ( { chatId, userId : ownerId } ) ) . not . toBeNull ( )
807+
808+ const { run, seeded } = await admitHeadlessRun ( ownerId , chatId , workspaceId )
809+
810+ expect ( run . status ) . toBe ( 'cancelled' )
811+ expect ( run . toolAdmissionClosedAt ) . not . toBeNull ( )
812+ expect ( run . completedAt ) . not . toBeNull ( )
813+ expect ( await runControl ( seeded ) ) . toEqual ( STOPPED )
814+ } )
815+
816+ /**
817+ * A headless run is admitted without writing the chat row, so only its read of the chat
818+ * serializes it with a deletion: holding the row queues both in a chosen order.
819+ */
820+ it . each ( [ 'admission' , 'deletion' ] as const ) (
821+ 'never leaves a headless run live while its chat is being deleted, when %s reaches the chat first' ,
822+ async ( first ) => {
823+ const ownerId = await seedUser ( )
824+ const workspaceId = await seedWorkspace ( ownerId )
825+ const chatId = await seedChat ( ownerId , workspaceId )
826+ const admit = ( ) => admitHeadlessRun ( ownerId , chatId , workspaceId )
827+ const remove = ( ) => softDeleteMothershipChat ( { chatId, userId : ownerId } )
828+
829+ const { admission, deletion } = await queueOnChat ( chatId , first , admit , remove )
830+
831+ const { run, seeded } = await admission
832+ expect ( await deletion ) . not . toBeNull ( )
833+ if ( first === 'admission' ) {
834+ await expect . poll ( pushedStops ) . toEqual ( [ seeded . streamId ] )
835+ expect ( await hasStop ( seeded ) ) . toBe ( true )
836+ } else {
837+ expect ( run . status ) . toBe ( 'cancelled' )
838+ expect ( pushedStops ( ) ) . toEqual ( [ ] )
839+ }
840+ expect ( await toolAdmissionClosedAt ( seeded ) ) . not . toBeNull ( )
841+ expect ( await runControl ( seeded ) ) . toEqual ( STOPPED )
842+ }
843+ )
754844} )
0 commit comments