Skip to content

Commit 1745793

Browse files
committed
fix(run-workflow): race condition with upsert async tool
1 parent 712801a commit 1745793

4 files changed

Lines changed: 55 additions & 23 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
MothershipStreamV1SpanPayloadKind,
2626
MothershipStreamV1ToolOutcome,
2727
MothershipStreamV1ToolPhase,
28+
MothershipStreamV1ToolStatus,
2829
} from '@/lib/copilot/generated/mothership-stream-v1'
2930
import {
3031
CrawlWebsite,
@@ -2258,7 +2259,9 @@ export function useChat(
22582259
}
22592260

22602261
const name = payload.toolName
2261-
const isPartial = payload.partial === true
2262+
const isPartial =
2263+
payload.partial === true ||
2264+
payload.status === MothershipStreamV1ToolStatus.generating
22622265
if (name === ToolSearchToolRegex.id || isToolHiddenInUi(name)) {
22632266
break
22642267
}

apps/sim/lib/copilot/request/go/stream.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { FatalSseEventError, processSSEStream } from '@/lib/copilot/request/go/parser'
2121
import {
2222
handleSubagentRouting,
23+
prePersistClientExecutableToolCall,
2324
sseHandlers,
2425
subAgentHandlers,
2526
} from '@/lib/copilot/request/handlers'
@@ -310,6 +311,8 @@ export async function runStreamLoop(
310311
state: filePreviewAdapterState,
311312
})
312313

314+
await prePersistClientExecutableToolCall(streamEvent, context)
315+
313316
try {
314317
await options.onEvent?.(streamEvent)
315318
} catch (error) {

apps/sim/lib/copilot/request/handlers/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import { handleRunEvent } from './run'
88
import { handleSessionEvent } from './session'
99
import { handleSpanEvent } from './span'
1010
import { handleTextEvent } from './text'
11-
import { handleToolEvent } from './tool'
11+
import { handleToolEvent, prePersistClientExecutableToolCall } from './tool'
1212
import type { StreamHandler } from './types'
1313

14+
export { prePersistClientExecutableToolCall }
1415
export type { StreamHandler, ToolScope } from './types'
1516

1617
const logger = createLogger('CopilotHandlerRouting')

apps/sim/lib/copilot/request/handlers/tool.ts

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,52 @@ function applyToolDisplay(
5454
if (displayTitle) toolCall.displayTitle = displayTitle
5555
}
5656

57+
/**
58+
* Upsert the durable `async_tool_calls` row before the authoritative tool-call
59+
* SSE frame is forwarded to the client, so `/api/copilot/confirm` can never
60+
* race ahead of the row that identifies the call. This is the sole
61+
* persistence point for client-executable tools; gating mirrors the
62+
* client-wait branch in `dispatchToolExecution`.
63+
*/
64+
export async function prePersistClientExecutableToolCall(
65+
event: StreamEvent,
66+
context: StreamingContext
67+
): Promise<void> {
68+
if (event.type !== 'tool') return
69+
if (!isToolCallStreamEvent(event)) return
70+
71+
const data = event.payload
72+
const isGenerating = data.status === TOOL_CALL_STATUS.generating
73+
const isPartial = data.partial === true || isGenerating
74+
if (isPartial) return
75+
76+
const ui = getToolCallUI(data)
77+
if (!ui.clientExecutable) return
78+
79+
const catalogEntry = getToolEntry(data.toolName)
80+
const isInternal = ui.internal === true || catalogEntry?.internal === true
81+
if (isInternal) return
82+
83+
const delegateWorkflowRunToClient = isWorkflowToolName(data.toolName)
84+
if (isSimExecuted(data.toolName) && !delegateWorkflowRunToClient) return
85+
86+
if (!context.runId) return
87+
88+
await upsertAsyncToolCall({
89+
runId: context.runId,
90+
toolCallId: data.toolCallId,
91+
toolName: data.toolName,
92+
args: data.arguments,
93+
status: MothershipStreamV1AsyncToolRecordStatus.running,
94+
}).catch((err) => {
95+
logger.warn('Failed to pre-persist async tool row before forwarding call frame', {
96+
toolCallId: data.toolCallId,
97+
toolName: data.toolName,
98+
error: err instanceof Error ? err.message : String(err),
99+
})
100+
})
101+
}
102+
57103
/**
58104
* Unified tool event handler for both main and subagent scopes.
59105
*
@@ -365,11 +411,6 @@ async function dispatchToolExecution(
365411
}
366412
} else {
367413
toolCall.status = 'executing'
368-
// Span covers the entire "wait for browser/client to execute this
369-
// tool and report back" window — typically the single largest
370-
// non-LLM latency contributor for mothership requests that use
371-
// client-side tools. Before this, the wait was uninstrumented and
372-
// only visible as a gap in the waterfall.
373414
const pendingPromise = withCopilotSpan(
374415
TraceSpan.CopilotToolWaitForClientResult,
375416
{
@@ -379,22 +420,6 @@ async function dispatchToolExecution(
379420
...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}),
380421
},
381422
async (span) => {
382-
await upsertAsyncToolCall({
383-
runId: context.runId,
384-
toolCallId,
385-
toolName,
386-
args,
387-
status: MothershipStreamV1AsyncToolRecordStatus.running,
388-
}).catch((err) => {
389-
logger.warn(
390-
`Failed to persist async tool row for client-executable ${scopeLabel}tool`,
391-
{
392-
toolCallId,
393-
toolName,
394-
error: err instanceof Error ? err.message : String(err),
395-
}
396-
)
397-
})
398423
const completion = await waitForToolCompletion(
399424
toolCallId,
400425
options.timeout || STREAM_TIMEOUT_MS,

0 commit comments

Comments
 (0)