@@ -42,7 +42,10 @@ import {
4242 settleClientWorkflowToolExecution ,
4343 upsertAsyncToolCall ,
4444} from '@/lib/mothership/async-runs/repository'
45- import { getToolConfirmation } from '@/lib/mothership/persistence/tool-confirm'
45+ import {
46+ getToolConfirmation ,
47+ publishToolConfirmation ,
48+ } from '@/lib/mothership/persistence/tool-confirm'
4649import { waitForWorkflowToolCompletion } from '@/lib/mothership/request/tools/client'
4750import { raceWorkflowToolClientPickup } from '@/lib/mothership/request/tools/workflow-client-fallback'
4851import {
@@ -207,10 +210,13 @@ describe.runIf(Boolean(redisUrl))('settled client-claimed workflow tools', () =>
207210 }
208211
209212 /**
210- * A claimed call whose bound execution paused at a human approval: the execute route settled the
211- * claim and its own report left the call running, as it does for every paused execution .
213+ * A claimed call whose bound execution paused (a paused run's log stays `pending`) and whose
214+ * execute route settled the claim, before the route reports it .
212215 */
213- async function claimedAndPaused ( snapshot : string = UNRESUMABLE_SNAPSHOT ) {
216+ async function claimedAndPausedUnreported (
217+ pauseKind : 'human' | 'time' ,
218+ snapshot : string = UNRESUMABLE_SNAPSHOT
219+ ) {
214220 const { toolCallId, executionId } = await claimed ( )
215221 await executionLog ( toolCallId , executionId , 'pending' )
216222 await db . insert ( pausedExecutions ) . values ( {
@@ -226,17 +232,26 @@ describe.runIf(Boolean(redisUrl))('settled client-claimed workflow tools', () =>
226232 registeredAt : new Date ( ) . toISOString ( ) ,
227233 resumeStatus : 'paused' ,
228234 snapshotReady : true ,
229- pauseKind : 'human' ,
235+ pauseKind,
230236 } ,
231237 } ,
232238 totalPauseCount : 1 ,
233239 } )
234240 await settleClientWorkflowToolExecution ( toolCallId , executionId )
235- await reportSettledClientWorkflowTool ( { toolCallId, executionId, workflowId } )
236- expect ( await toolRow ( toolCallId ) ) . toMatchObject ( { status : 'running' , result : null } )
237241 return { toolCallId, executionId }
238242 }
239243
244+ /**
245+ * A claimed call whose bound execution paused until a scheduled time: the run resumes by
246+ * itself, so the execute route's report leaves the call held for the resume to settle.
247+ */
248+ async function claimedAndPaused ( snapshot : string = UNRESUMABLE_SNAPSHOT ) {
249+ const ids = await claimedAndPausedUnreported ( 'time' , snapshot )
250+ await reportSettledClientWorkflowTool ( { ...ids , workflowId } )
251+ expect ( await toolRow ( ids . toolCallId ) ) . toMatchObject ( { status : 'running' , result : null } )
252+ return ids
253+ }
254+
240255 async function setLogStatus ( executionId : string , status : 'completed' | 'failed' | 'cancelled' ) {
241256 await db
242257 . update ( workflowExecutionLogs )
@@ -461,6 +476,142 @@ describe.runIf(Boolean(redisUrl))('settled client-claimed workflow tools', () =>
461476 expect ( await toolRow ( toolCallId ) ) . toMatchObject ( { status : 'failed' , claimedBy : null } )
462477 } )
463478
479+ it ( 'answers a run paused for a person with the link to its form, and keeps that answer when the run is later cancelled' , async ( ) => {
480+ const { toolCallId, executionId } = await claimedAndPausedUnreported ( 'human' )
481+ const waiting = waitForWorkflowToolCompletion ( { toolCallId, workflowId, timeoutMs : WAIT_MS } )
482+
483+ await reportSettledClientWorkflowTool ( { toolCallId, executionId, workflowId } )
484+
485+ const paused = {
486+ success : true ,
487+ status : 'paused' ,
488+ workflowId,
489+ executionId,
490+ resumeUrl : `http://localhost:3000/resume/${ workflowId } /${ executionId } ` ,
491+ }
492+ const completion = await waiting
493+ expect ( completion ) . toMatchObject ( { status : 'success' , data : paused } )
494+ expect ( completion ?. message ) . toContain ( paused . resumeUrl )
495+ const settled = await toolRow ( toolCallId )
496+ expect ( settled ) . toMatchObject ( { status : 'completed' , claimedBy : null , result : paused } )
497+ expect ( await publishedConfirmation ( toolCallId ) ) . toMatchObject ( {
498+ status : 'success' ,
499+ executionId,
500+ } )
501+
502+ // The run is cancelled before anyone submits: its own report finds the call already answered.
503+ expect ( await PauseResumeManager . beginPausedCancellation ( executionId , workflowId ) ) . toBe ( true )
504+ expect ( await PauseResumeManager . completePausedCancellation ( executionId , workflowId ) ) . toBe ( true )
505+ expect ( await toolRow ( toolCallId ) ) . toMatchObject ( {
506+ status : 'completed' ,
507+ completedAt : settled . completedAt ,
508+ result : paused ,
509+ } )
510+ } )
511+
512+ it ( "answers a paused run the worker's lapsed wait finds when the route's report never landed" , async ( ) => {
513+ const { toolCallId, executionId } = await claimedAndPausedUnreported ( 'human' )
514+ const runOnServer = vi . fn ( )
515+
516+ const outcome = await raceWorkflowToolClientPickup ( {
517+ toolCallId,
518+ workflowId,
519+ timeoutMs : WAIT_MS ,
520+ graceMs : WAIT_MS ,
521+ waitTimeoutMs : 500 ,
522+ runOnServer,
523+ } )
524+
525+ expect ( outcome ) . toMatchObject ( {
526+ winner : 'client' ,
527+ completion : { status : 'success' , data : { status : 'paused' , workflowId, executionId } } ,
528+ } )
529+ expect ( runOnServer ) . not . toHaveBeenCalled ( )
530+ expect ( await toolRow ( toolCallId ) ) . toMatchObject ( { status : 'completed' , claimedBy : null } )
531+ } )
532+
533+ it ( 'answers a paused call still running when the person submitted before Chat read the pause' , async ( ) => {
534+ const { toolCallId, executionId } = await claimedAndPausedUnreported ( 'human' )
535+ await reportSettledClientWorkflowTool ( { toolCallId, executionId, workflowId } )
536+ // The resume took the pause point and the resumed run is going.
537+ const [ pausedRow ] = await db
538+ . select ( { id : pausedExecutions . id , pausePoints : pausedExecutions . pausePoints } )
539+ . from ( pausedExecutions )
540+ . where ( eq ( pausedExecutions . executionId , executionId ) )
541+ await db
542+ . update ( pausedExecutions )
543+ . set ( {
544+ pausePoints : {
545+ [ APPROVAL_CONTEXT_ID ] : {
546+ ...( pausedRow . pausePoints as Record < string , Record < string , unknown > > ) [
547+ APPROVAL_CONTEXT_ID
548+ ] ,
549+ resumeStatus : 'resuming' ,
550+ } ,
551+ } ,
552+ } )
553+ . where ( eq ( pausedExecutions . id , pausedRow . id ) )
554+ await db
555+ . update ( workflowExecutionLogs )
556+ . set ( { status : 'running' } )
557+ . where ( eq ( workflowExecutionLogs . executionId , executionId ) )
558+
559+ const completion = await waitForWorkflowToolCompletion ( {
560+ toolCallId,
561+ workflowId,
562+ timeoutMs : WAIT_MS ,
563+ } )
564+
565+ expect ( completion ) . toMatchObject ( {
566+ status : 'background' ,
567+ data : { status : 'running' , workflowId, executionId } ,
568+ } )
569+ } )
570+
571+ it ( "never hands a call the link to another call's paused run" , async ( ) => {
572+ const other = await claimedAndPausedUnreported ( 'human' )
573+ const { toolCallId } = await claimed ( )
574+ const waiting = waitForWorkflowToolCompletion ( { toolCallId, workflowId, timeoutMs : WAIT_MS } )
575+ const claimedPause = { status : 'paused' , workflowId, executionId : other . executionId }
576+
577+ await completeAsyncToolCall ( { toolCallId, status : 'completed' , result : claimedPause } )
578+ publishToolConfirmation ( {
579+ toolCallId,
580+ status : 'success' ,
581+ timestamp : new Date ( ) . toISOString ( ) ,
582+ data : claimedPause ,
583+ executionId : other . executionId ,
584+ } )
585+
586+ const completion = await waiting
587+ expect ( completion ?. data ) . not . toHaveProperty ( 'resumeUrl' )
588+ expect ( JSON . stringify ( completion ) ) . not . toContain ( '/resume/' )
589+ } )
590+
591+ it ( 'answers from the run log when a confirmation claims a pause the run is not in' , async ( ) => {
592+ const { toolCallId, executionId } = await claimedAndSettled ( )
593+ const waiting = waitForWorkflowToolCompletion ( { toolCallId, workflowId, timeoutMs : WAIT_MS } )
594+ const claimedPause = {
595+ status : 'paused' ,
596+ workflowId,
597+ executionId,
598+ resumeUrl : 'https://elsewhere.test' ,
599+ }
600+
601+ await completeAsyncToolCall ( { toolCallId, status : 'completed' , result : claimedPause } )
602+ publishToolConfirmation ( {
603+ toolCallId,
604+ status : 'success' ,
605+ timestamp : new Date ( ) . toISOString ( ) ,
606+ data : claimedPause ,
607+ executionId,
608+ } )
609+
610+ const completion = await waiting
611+ expect ( completion ?. status ) . toBe ( 'success' )
612+ expect ( completion ?. data ) . toEqual ( { success : true , workflowId, executionId } )
613+ } )
614+
464615 it ( 'moves a queued async run to the background when the browser never reports' , async ( ) => {
465616 const { toolCallId, executionId } = await claimed ( { workflowId, async : true } )
466617 const waiting = waitForWorkflowToolCompletion ( { toolCallId, workflowId, timeoutMs : WAIT_MS } )
0 commit comments