Skip to content

Commit a1748e5

Browse files
committed
feat(mothership): answer a workflow tool's run still running past the worker's wait
A run_workflow* call held the turn until its run settled, up to the execution limit, while a synchronous workflows run can already detach after --wait-timeout (#9464). Sim now declares workflow-tool-wait-timeout and honors the waitTimeoutMs a workflow tool frame names (synced from the mothership contract): - Server-run call: the handler gives the run its execution id up front and, once the wait passes, answers {status: running, executionId, workflowId}. The run goes on in-process; the tool execution keeps holding it, so its lease and a Stop through the call's abort signal still reach it. - Browser-run call: once the wait passes and a browser holds the call's execution claim, the call is settled background, the detach a browser sends on pagehide. The browser's run goes on, still bound to the call, so a Stop cancels it. - A background workflow completion now carries status: running beside its ids, the shape the worker waits on. A frame without waitTimeoutMs, or one with a malformed value, holds the call as before.
1 parent 3dfcaa0 commit a1748e5

18 files changed

Lines changed: 544 additions & 84 deletions

File tree

‎apps/sim/app/api/copilot/confirm/route.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1036,7 +1036,7 @@ describe('Copilot Confirm API Route', () => {
10361036
status: 'background',
10371037
message: 'Workflow execution is continuing in the background.',
10381038
timestamp: expect.any(String),
1039-
data: { workflowId: 'workflow-1', executionId: 'unbound-execution' },
1039+
data: { workflowId: 'workflow-1', executionId: 'unbound-execution', status: 'running' },
10401040
})
10411041
})
10421042

‎apps/sim/lib/mothership/chat/payload.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
ChatPayloadSchema,
3535
TOOL_DEADLINE_CAPABILITY,
3636
WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
37+
WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
3738
} from '@/lib/mothership/generated/protocol'
3839
import { searchUsersV2Tool } from '@/tools/github/search_users'
3940
import { gmailListLabelsV2Tool } from '@/tools/gmail/list_labels'
@@ -430,6 +431,7 @@ describe('buildCopilotRequestPayload', () => {
430431
'workflow-tool-pickup',
431432
TOOL_DEADLINE_CAPABILITY,
432433
WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
434+
WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
433435
])
434436
}
435437
)
@@ -514,6 +516,7 @@ describe('buildCopilotRequestPayload', () => {
514516
expect(payload.clientCapabilities).toEqual([
515517
TOOL_DEADLINE_CAPABILITY,
516518
WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
519+
WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
517520
])
518521
if (owner === 'organization') expect(payload).not.toHaveProperty('integrationTools')
519522
}
@@ -854,6 +857,7 @@ describe('Assistant payload', () => {
854857
expect(payload.clientCapabilities).toEqual([
855858
TOOL_DEADLINE_CAPABILITY,
856859
WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
860+
WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
857861
])
858862
expect(payload.mode).toBe('assistant')
859863
expect(payload.assistantSearch).toEqual({ source: 'slack', documentIds: ['document-1'] })

‎apps/sim/lib/mothership/chat/payload.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
type ModelSelection,
3939
TOOL_DEADLINE_CAPABILITY,
4040
WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
41+
WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
4142
} from '@/lib/mothership/generated/protocol'
4243
import type { VfsSnapshotV1 } from '@/lib/mothership/generated/vfs-snapshot-v1'
4344
import { getToolEntry } from '@/lib/mothership/tool-executor/router'
@@ -562,13 +563,15 @@ export async function buildCopilotRequestPayload(params: BuildPayloadParams): Pr
562563
: {}),
563564
// The mounted chat view executes client-routed workflow tools (run panel UX), so the
564565
// UI declares that capability explicitly; headless callers omit it and the server runs
565-
// those tools immediately. The deadline and run-wait tokens are this server's own: it
566-
// bounds every handed call by the deadline its frame names, and its CLI lets a
567-
// synchronous `workflows run` outlast `--wait-timeout`.
566+
// those tools immediately. The deadline and wait tokens are this server's own: it
567+
// bounds every handed call by the deadline its frame names, its CLI lets a synchronous
568+
// `workflows run` outlast `--wait-timeout`, and a workflow tool's run outlasts the
569+
// `waitTimeoutMs` its frame names.
568570
clientCapabilities: [
569571
...(isAssistant ? [] : ['workflow-tool-pickup']),
570572
TOOL_DEADLINE_CAPABILITY,
571573
WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
574+
WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
572575
],
573576
}
574577
}

‎apps/sim/lib/mothership/generated/protocol.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,14 @@ export interface StreamToolReplay {
297297
*/
298298
export interface StreamToolDeadline {
299299
deadlineMs?: number | undefined;
300+
/**
301+
* For a workflow tool (`run_workflow`, `run_workflow_until_block`, `run_from_block`,
302+
* `run_block`) on a Sim that declares {@link WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY}: how long
303+
* Sim waits for the call's run to settle once it starts waiting on it. A run still going then
304+
* keeps going and the call settles with a {@link WorkflowToolRunningResult}. Absent = held
305+
* until the run settles.
306+
*/
307+
waitTimeoutMs?: number | undefined;
300308
}
301309

302310
/**
@@ -322,6 +330,24 @@ export const WORKSPACE_FILE_COPY_CAPABILITY = "workspace-file-copy";
322330
*/
323331
export const WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY = "workflow-run-wait-timeout";
324332

333+
/**
334+
* The `clientCapabilities` token a Sim sends when it honors `StreamToolDeadline.waitTimeoutMs`
335+
* on a workflow tool call: a run that outlasts it continues and the call settles with a
336+
* {@link WorkflowToolRunningResult}, so the worker waits on the run in slices.
337+
*/
338+
export const WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY = "workflow-tool-wait-timeout";
339+
340+
/**
341+
* A workflow tool call's output when its run outlasted the call's `waitTimeoutMs`: the run is
342+
* still going under `executionId`, readable as a run of `workflowId` (`workflows runs wait`).
343+
*/
344+
export interface WorkflowToolRunningResult {
345+
status: "running";
346+
executionId: string;
347+
workflowId: string;
348+
message?: string | undefined;
349+
}
350+
325351
/** POST /api/mothership — the chat request sim sends. */
326352
export interface ChatRequest extends StreamResponseReceipt {
327353
fileOwnerProtocolVersion?: 1 | undefined;
@@ -367,8 +393,9 @@ export interface ChatRequest extends StreamResponseReceipt {
367393
* grace windows and run tools server-side immediately. ABSENT = older/unknown caller —
368394
* dispatch keeps its conservative grace (deploy-skew safe: a stale tab that predates
369395
* this field still gets waited on). Known capability: "workflow-tool-pickup". Sim also
370-
* declares its own {@link TOOL_DEADLINE_CAPABILITY}, {@link WORKSPACE_FILE_COPY_CAPABILITY} and
371-
* {@link WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY} here.
396+
* declares its own {@link TOOL_DEADLINE_CAPABILITY}, {@link WORKSPACE_FILE_COPY_CAPABILITY},
397+
* {@link WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY} and {@link WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY}
398+
* here.
372399
*/
373400
clientCapabilities?: string[] | undefined;
374401
/**

‎apps/sim/lib/mothership/request/handlers/handlers.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,35 @@ describe('sse-handlers tool lifecycle', () => {
732732
expect(context.toolCalls.get('takeover-1')?.deadlineAt).toBeLessThan(before + 60_000)
733733
})
734734

735+
it('hands a workflow tool the wait its frame names, and no other tool', async () => {
736+
executeTool.mockResolvedValue({ success: true, output: {} })
737+
const frame = (toolCallId: string, toolName: string, waitTimeoutMs: unknown) => {
738+
const event = deadlineFrame(900_000, {
739+
toolCallId,
740+
toolName,
741+
arguments: { workflowId: 'workflow-1' },
742+
})
743+
return { ...event, payload: { ...event.payload, waitTimeoutMs } } as StreamEvent
744+
}
745+
await sseHandlers.tool(frame('run-1', 'run_workflow', 240_000), context, execContext, {
746+
interactive: false,
747+
})
748+
await sseHandlers.tool(frame('audio-2', 'generate_audio', 240_000), context, execContext, {
749+
interactive: false,
750+
})
751+
await sseHandlers.tool(frame('run-2', 'run_workflow', -5), context, execContext, {
752+
interactive: false,
753+
})
754+
await Promise.all(context.pendingToolPromises.values())
755+
const handed = (toolCallId: string) =>
756+
executeTool.mock.calls.find(
757+
([, , toolContext]) => toolContext?.toolCallId === toolCallId
758+
)?.[2]
759+
expect(handed('run-1')?.workflowRunWaitMs).toBe(240_000)
760+
expect(handed('audio-2')).not.toHaveProperty('workflowRunWaitMs')
761+
expect(handed('run-2')).not.toHaveProperty('workflowRunWaitMs')
762+
})
763+
735764
/** Runs a desktop-local read whose frame names `deadlineMs`, recording the emitted events. */
736765
async function runLocalRead(deadlineMs: number) {
737766
const onEvent = vi.fn()

‎apps/sim/lib/mothership/request/handlers/tool.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { sealClientToolContext } from '@/lib/mothership/request/tools/client-com
4444
import {
4545
capToToolDeadline,
4646
frameToolDeadlineAt,
47+
frameWorkflowWaitMs,
4748
TOOL_DEADLINE_BEFORE_START_MESSAGE,
4849
TOOL_DEADLINE_EXCEEDED_MESSAGE,
4950
toDeadlineDate,
@@ -742,6 +743,8 @@ async function handleCallPhase(
742743
if (parentToolCallId) toolCall.parentToolCallId = parentToolCallId
743744
if (data.execName) toolCall.execName = data.execName
744745
toolCall.deadlineAt ??= frameToolDeadlineAt(data.deadlineMs, Date.now())
746+
if (isWorkflowToolName(toolName))
747+
toolCall.waitTimeoutMs ??= frameWorkflowWaitMs(data.waitTimeoutMs)
745748

746749
const readPath = typeof args?.path === 'string' ? args.path : undefined
747750
if (toolName === 'read' && readPath?.startsWith('internal/')) return
@@ -1110,6 +1113,9 @@ async function dispatchToolExecution(
11101113
options.clientToolPickupExpected === false
11111114
? 0
11121115
: COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS,
1116+
...(toolCall.waitTimeoutMs !== undefined
1117+
? { waitTimeoutMs: toolCall.waitTimeoutMs }
1118+
: {}),
11131119
abortSignal: options.abortSignal,
11141120
registry: execContext.resolvedSecretTraceRegistry,
11151121
runOnServer: (boundExecutionId) => {

‎apps/sim/lib/mothership/request/tools/client.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,8 @@ describe('workflow client tool completion', () => {
729729
expect(completion).toEqual({
730730
status: 'background',
731731
message: 'Workflow execution is continuing in the background.',
732-
data: { workflowId: 'workflow-1', executionId: 'execution-1' },
732+
// A WorkflowToolRunningResult: the worker waits on the run by these ids.
733+
data: { workflowId: 'workflow-1', executionId: 'execution-1', status: 'running' },
733734
})
734735
expect(registry.isComplete()).toBe(true)
735736
expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled()

‎apps/sim/lib/mothership/request/tools/deadline.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ export function frameToolDeadlineAt(deadlineMs: unknown, receivedAt: number): nu
2020
return receivedAt + deadlineMs - TOOL_DEADLINE_SETTLE_MARGIN_MS
2121
}
2222

23+
/**
24+
* How long a workflow tool call waits for its run before settling still running, from the
25+
* frame's `waitTimeoutMs`. Undefined when the frame names none or a malformed value: the call
26+
* holds until its run settles.
27+
*/
28+
export function frameWorkflowWaitMs(waitTimeoutMs: unknown): number | undefined {
29+
return typeof waitTimeoutMs === 'number' &&
30+
Number.isSafeInteger(waitTimeoutMs) &&
31+
waitTimeoutMs > 0 &&
32+
waitTimeoutMs <= MAX_FRAME_DEADLINE_MS
33+
? waitTimeoutMs
34+
: undefined
35+
}
36+
2337
/** What the model learns when a call's deadline passed before the call could start. */
2438
export const TOOL_DEADLINE_BEFORE_START_MESSAGE =
2539
'This tool call ran out of its time limit before it could start, so it did not run. Nothing changed; it is safe to try again with a smaller request.'

‎apps/sim/lib/mothership/request/tools/executor.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,13 +369,17 @@ class ToolExecutionTimeoutError extends Error {
369369

370370
/** Builds the per-call context from the turn-scoped execution context. */
371371
export function buildToolExecutionContext(
372-
toolCall: Pick<ToolCallState, 'id' | 'parentToolCallId' | 'params' | 'targetWorkspaceId'>,
372+
toolCall: Pick<
373+
ToolCallState,
374+
'id' | 'parentToolCallId' | 'params' | 'targetWorkspaceId' | 'waitTimeoutMs'
375+
>,
373376
execContext: ExecutionContext
374377
): ExecutionContext {
375378
return {
376379
...execContext,
377380
toolCallId: toolCall.id,
378381
...(toolCall.targetWorkspaceId ? { targetWorkspaceId: toolCall.targetWorkspaceId } : {}),
382+
...(toolCall.waitTimeoutMs !== undefined ? { workflowRunWaitMs: toolCall.waitTimeoutMs } : {}),
379383
resolvedSecretTraceRegistry: execContext.resolvedSecretTraceRegistry?.forkForInputPaths([]),
380384
...(toolCall.parentToolCallId ? { parentToolCallId: toolCall.parentToolCallId } : {}),
381385
}

‎apps/sim/lib/mothership/request/tools/workflow-client-fallback.test.ts‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ import {
88
} from '@sim/testing/mocks/mothership-client-tool-waiter.mock'
99
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1010

11-
const { recordDegraded } = vi.hoisted(() => ({
11+
const { recordDegraded, settleClientToolCall } = vi.hoisted(() => ({
1212
recordDegraded: vi.fn(),
13+
settleClientToolCall: vi.fn(),
14+
}))
15+
16+
vi.mock('@/lib/mothership/request/tools/client-settlement.server', () => ({
17+
settleClientToolCall,
1318
}))
1419

1520
vi.mock('@/lib/mothership/request/metrics', () => ({ recordDegraded }))
@@ -24,6 +29,8 @@ vi.mock('@/lib/mothership/async-runs/repository', () => mothershipAsyncRunsMock)
2429
import { raceWorkflowToolClientPickup } from '@/lib/mothership/request/tools/workflow-client-fallback'
2530

2631
const claimWorkflowToolExecution = mothershipAsyncRunsMockFns.mockClaimWorkflowToolExecution
32+
const getAsyncToolCall = mothershipAsyncRunsMockFns.mockGetAsyncToolCall
33+
const getClaimedWorkflowExecutionId = mothershipAsyncRunsMockFns.mockGetClaimedWorkflowExecutionId
2734

2835
const GRACE_MS = 30_000
2936
const TIMEOUT_MS = 3_600_000
@@ -216,3 +223,86 @@ describe('raceWorkflowToolClientPickup', () => {
216223
expect(params.runOnServer).not.toHaveBeenCalled()
217224
})
218225
})
226+
227+
describe('a browser-run workflow call past its wait', () => {
228+
const WAIT_MS = 240_000
229+
beforeEach(() => {
230+
vi.useFakeTimers()
231+
waiterSignals = []
232+
settleClientToolCall.mockReset()
233+
getAsyncToolCall.mockReset()
234+
getClaimedWorkflowExecutionId.mockImplementation((claimedBy?: string | null) =>
235+
claimedBy?.startsWith('workflow:') ? claimedBy.slice('workflow:'.length) : undefined
236+
)
237+
pendingUntilAborted()
238+
// A browser already holds the call, so the server keeps waiting on it.
239+
claimWorkflowToolExecution.mockResolvedValue(null)
240+
})
241+
afterEach(() => {
242+
vi.useRealTimers()
243+
})
244+
245+
const browserClaim = {
246+
status: 'running',
247+
claimedBy: 'workflow:execution-1',
248+
clientWorkflowExecutionId: 'execution-1',
249+
}
250+
251+
it('settles the call running in the background by the browser run ids', async () => {
252+
getAsyncToolCall.mockResolvedValue(browserClaim)
253+
const params = baseParams({ waitTimeoutMs: WAIT_MS })
254+
void raceWorkflowToolClientPickup(params as never)
255+
256+
await vi.advanceTimersByTimeAsync(WAIT_MS - 1)
257+
expect(settleClientToolCall).not.toHaveBeenCalled()
258+
await vi.advanceTimersByTimeAsync(1)
259+
260+
expect(settleClientToolCall).toHaveBeenCalledWith({
261+
toolCallId: 'tool-1',
262+
status: 'background',
263+
message: 'Workflow execution is continuing in the background.',
264+
data: { workflowId: 'workflow-1', executionId: 'execution-1', status: 'running' },
265+
executionId: 'execution-1',
266+
guard: { kind: 'open' },
267+
})
268+
// The browser's run goes on; only the server's wait ended.
269+
expect(params.runOnServer).not.toHaveBeenCalled()
270+
})
271+
272+
it('re-reads a call no browser has claimed yet until one does', async () => {
273+
getAsyncToolCall
274+
.mockResolvedValueOnce({
275+
status: 'running',
276+
claimedBy: null,
277+
clientWorkflowExecutionId: null,
278+
})
279+
.mockResolvedValue(browserClaim)
280+
void raceWorkflowToolClientPickup(baseParams({ waitTimeoutMs: WAIT_MS }) as never)
281+
282+
await vi.advanceTimersByTimeAsync(WAIT_MS)
283+
expect(settleClientToolCall).not.toHaveBeenCalled()
284+
await vi.advanceTimersByTimeAsync(5_000)
285+
expect(settleClientToolCall).toHaveBeenCalledWith(
286+
expect.objectContaining({ status: 'background', executionId: 'execution-1' })
287+
)
288+
})
289+
290+
it('leaves a call that settled first alone', async () => {
291+
waitForWorkflowToolCompletion.mockResolvedValue({ status: 'success', data: { ok: true } })
292+
const outcome = await raceWorkflowToolClientPickup(
293+
baseParams({ waitTimeoutMs: WAIT_MS }) as never
294+
)
295+
await vi.advanceTimersByTimeAsync(WAIT_MS * 2)
296+
expect(outcome.winner).toBe('client')
297+
expect(getAsyncToolCall).not.toHaveBeenCalled()
298+
expect(settleClientToolCall).not.toHaveBeenCalled()
299+
})
300+
301+
it('holds a call that names no wait', async () => {
302+
getAsyncToolCall.mockResolvedValue(browserClaim)
303+
void raceWorkflowToolClientPickup(baseParams() as never)
304+
await vi.advanceTimersByTimeAsync(TIMEOUT_MS / 2)
305+
expect(getAsyncToolCall).not.toHaveBeenCalled()
306+
expect(settleClientToolCall).not.toHaveBeenCalled()
307+
})
308+
})

0 commit comments

Comments
 (0)