@@ -33,11 +33,14 @@ import { eq, inArray } from 'drizzle-orm'
3333import { closeRedisConnection , getRedisClient } from '@/lib/core/config/redis'
3434import { SIM_TOOL_EXECUTION_VERSION } from '@/lib/mothership/async-runs/lifecycle'
3535import {
36+ claimToolExecution ,
3637 claimWorkflowToolExecution ,
3738 completeAsyncToolCall ,
3839 detachAsyncToolCall ,
3940 getDetachedWorkflowExecutions ,
41+ prepareWorkbenchAccess ,
4042 settleClientWorkflowToolExecution ,
43+ upsertAsyncToolCall ,
4144} from '@/lib/mothership/async-runs/repository'
4245import { getToolConfirmation } from '@/lib/mothership/persistence/tool-confirm'
4346import { waitForWorkflowToolCompletion } from '@/lib/mothership/request/tools/client'
@@ -46,6 +49,7 @@ import {
4649 reportQueuedClientWorkflowTool ,
4750 reportSettledClientWorkflowTool ,
4851} from '@/lib/mothership/request/tools/workflow-client-settlement'
52+ import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key'
4953import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
5054
5155/** Longer than the waiter's durable poll, far shorter than the hour it used to park for. */
@@ -532,40 +536,175 @@ describe.runIf(Boolean(redisUrl))('settled client-claimed workflow tools', () =>
532536 } )
533537
534538 it ( "names the runs this run's calls started and left running, for a Stop to cancel" , async ( ) => {
535- const insertCall = async ( executableName : string , args : unknown , result : unknown ) =>
536- db . insert ( copilotAsyncToolCalls ) . values ( {
539+ const insertCall = async ( executableName : string , args : unknown , result : unknown ) => {
540+ const toolCallId = generateId ( )
541+ await db . insert ( copilotAsyncToolCalls ) . values ( {
537542 runId,
538- toolCallId : generateId ( ) ,
543+ toolCallId,
539544 toolName : executableName ,
540545 executableName,
541546 args : args as Record < string , unknown > ,
542547 result : result as Record < string , unknown > ,
543548 status : 'completed' ,
544549 } )
545- const detachedCli = generateId ( )
546- const detachedTool = generateId ( )
550+ return toolCallId
551+ }
547552 const record = ( runIdValue : string , status : string ) =>
548553 JSON . stringify ( { runId : runIdValue , workflowId, status } )
549- await insertCall (
550- 'sim_cli' ,
551- { request : { invocation : { kind : 'cli' , argv : [ 'workflows' , 'run' , workflowId ] } } } ,
552- { exitCode : 4 , stdout : record ( detachedCli , 'running' ) , stderr : '' }
554+ const detachedCli = generateId ( )
555+ await executionLog (
556+ await insertCall (
557+ 'sim_cli' ,
558+ { request : { invocation : { kind : 'cli' , argv : [ 'workflows' , 'run' , workflowId ] } } } ,
559+ { exitCode : 4 , stdout : record ( detachedCli , 'running' ) , stderr : '' }
560+ ) ,
561+ detachedCli ,
562+ 'running'
553563 )
554564 // A wait on a run proves nothing about who started it.
555565 await insertCall (
556566 'sim_cli' ,
557567 { request : { invocation : { kind : 'cli' , argv : [ 'workflows' , 'runs' , 'wait' , 'other' ] } } } ,
558568 { exitCode : 4 , stdout : record ( generateId ( ) , 'running' ) , stderr : '' }
559569 )
570+ const detachedTool = generateId ( )
571+ await executionLog (
572+ await insertCall (
573+ 'run_workflow' ,
574+ { workflowId } ,
575+ { status : 'running' , executionId : detachedTool , workflowId }
576+ ) ,
577+ detachedTool ,
578+ 'running'
579+ )
580+ // Answered running but not yet logged: it may still be starting, and a Stop must reach it.
581+ const startingTool = generateId ( )
560582 await insertCall (
561- 'run_workflow ' ,
583+ 'run_from_block ' ,
562584 { workflowId } ,
563- { status : 'running' , executionId : detachedTool , workflowId }
585+ { status : 'running' , executionId : startingTool , workflowId }
564586 )
565587 await insertCall ( 'run_block' , { workflowId } , { success : true , executionId : generateId ( ) } )
566588
567589 expect ( ( await getDetachedWorkflowExecutions ( streamId , userId ) ) . sort ( ) ) . toEqual (
568- [ detachedCli , detachedTool ] . sort ( )
590+ [ detachedCli , detachedTool , startingTool ] . sort ( )
569591 )
570592 } )
593+
594+ it . each ( [ 'pending' , 'completed' , 'failed' , 'cancelled' ] as const ) (
595+ 'leaves a detached run whose log is %s for a Stop to skip' ,
596+ async ( logStatus ) => {
597+ const detachedCli = generateId ( )
598+ const detachedTool = generateId ( )
599+ const cliCall = generateId ( )
600+ const toolCall = generateId ( )
601+ await db . insert ( copilotAsyncToolCalls ) . values ( [
602+ {
603+ runId,
604+ toolCallId : cliCall ,
605+ toolName : 'sim_cli' ,
606+ executableName : 'sim_cli' ,
607+ args : {
608+ request : { invocation : { kind : 'cli' , argv : [ 'workflows' , 'run' , workflowId ] } } ,
609+ } ,
610+ result : {
611+ exitCode : 4 ,
612+ stdout : JSON . stringify ( { runId : detachedCli , workflowId, status : 'running' } ) ,
613+ stderr : '' ,
614+ } ,
615+ status : 'completed' ,
616+ } ,
617+ {
618+ runId,
619+ toolCallId : toolCall ,
620+ toolName : 'run_workflow' ,
621+ executableName : 'run_workflow' ,
622+ args : { workflowId } ,
623+ result : { status : 'running' , executionId : detachedTool , workflowId } ,
624+ status : 'completed' ,
625+ } ,
626+ ] )
627+ // The run went on past its call and then settled, or paused at a human approval: a paused
628+ // run's resume subscribes under the same id, so a cancel flag would fail it.
629+ await executionLog ( cliCall , detachedCli , logStatus )
630+ await executionLog ( toolCall , detachedTool , logStatus )
631+
632+ const cancellable = await getDetachedWorkflowExecutions ( streamId , userId )
633+ expect ( cancellable ) . not . toContain ( detachedCli )
634+ expect ( cancellable ) . not . toContain ( detachedTool )
635+ }
636+ )
637+
638+ /**
639+ * An earlier turn of a new chat whose `earlierTool` call is still executing under a live lease,
640+ * as a workflow tool answered still running is while its run goes on, and a later turn whose
641+ * own Sim call asks for the chat's workbench.
642+ */
643+ async function laterTurnWorkbenchAccess ( earlierTool : string ) {
644+ const turnChatId = generateId ( )
645+ await db . insert ( copilotChats ) . values ( {
646+ id : turnChatId ,
647+ userId,
648+ workspaceId,
649+ type : 'mothership' ,
650+ conversationId : generateId ( ) ,
651+ } )
652+ try {
653+ const turn = async ( startedAt : Date , toolName : string ) => {
654+ const turnRunId = generateId ( )
655+ await db . insert ( copilotRuns ) . values ( {
656+ id : turnRunId ,
657+ executionId : generateId ( ) ,
658+ chatId : turnChatId ,
659+ userId,
660+ workspaceId,
661+ streamId : generateId ( ) ,
662+ toolExecutionVersion : SIM_TOOL_EXECUTION_VERSION ,
663+ status : 'active' ,
664+ startedAt,
665+ requestContext : { source : 'headless_lifecycle' } ,
666+ } )
667+ const owner = {
668+ toolCallId : generateId ( ) ,
669+ runId : turnRunId ,
670+ userId,
671+ ownerToken : generateId ( ) ,
672+ }
673+ await upsertAsyncToolCall ( { runId : turnRunId , toolCallId : owner . toolCallId , toolName } )
674+ expect ( await claimToolExecution ( owner ) ) . toMatchObject ( { outcome : 'claimed' } )
675+ return owner
676+ }
677+ const earlier = await turn ( new Date ( Date . now ( ) - 60_000 ) , earlierTool )
678+ await completeAsyncToolCall ( {
679+ toolCallId : earlier . toolCallId ,
680+ status : 'completed' ,
681+ result : { status : 'running' , executionId : generateId ( ) , workflowId } ,
682+ } )
683+ // The earlier turn answered and ended; its call's execution still holds the run.
684+ await db
685+ . update ( copilotRuns )
686+ . set ( { status : 'complete' , toolAdmissionClosedAt : new Date ( ) } )
687+ . where ( eq ( copilotRuns . id , earlier . runId ) )
688+ const later = await turn ( new Date ( ) , 'function_execute' )
689+ return await prepareWorkbenchAccess ( {
690+ ...later ,
691+ sessionKey : chatSandboxSessionKey ( turnChatId ) ,
692+ } )
693+ } finally {
694+ await db . delete ( copilotChats ) . where ( eq ( copilotChats . id , turnChatId ) )
695+ }
696+ }
697+
698+ it ( "opens the workbench to a later turn while an earlier turn's workflow tool run goes on" , async ( ) => {
699+ expect ( await laterTurnWorkbenchAccess ( 'run_workflow' ) ) . toEqual ( {
700+ handlersPending : false ,
701+ processes : [ ] ,
702+ } )
703+ } )
704+
705+ it ( "keeps the workbench closed while an earlier turn's other tool is still executing" , async ( ) => {
706+ expect ( await laterTurnWorkbenchAccess ( 'function_execute' ) ) . toMatchObject ( {
707+ handlersPending : true ,
708+ } )
709+ } )
571710} )
0 commit comments