@@ -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