Skip to content

Commit a63c8fa

Browse files
committed
dot fixes
1 parent 797076c commit a63c8fa

12 files changed

Lines changed: 690 additions & 29 deletions

File tree

apps/sim/lib/copilot/async-runs/repository.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
completeAsyncToolCall,
1212
detachAsyncToolCall,
1313
getClaimedWorkflowExecutionId,
14+
markAsyncToolRunning,
1415
recordToolPermissionDecision,
1516
releaseWorkflowToolExecutionClaim,
1617
replaceTerminalAsyncToolCallResult,
@@ -162,6 +163,29 @@ describe('async tool repository single-row semantics', () => {
162163
await expect(claimWorkflowToolExecution('workflow-tool', 'execution-2')).resolves.toBeNull()
163164
})
164165

166+
it('overwrites a workflow execution claim once the sim path starts running it', async () => {
167+
// The server-side fallback claims `workflow:<id>` and then immediately runs
168+
// the tool, whose executor re-marks the row as running under 'sim-stream'.
169+
// The claim value is therefore NOT durable identity — only its
170+
// `claimedBy IS NULL` precondition is load-bearing, since that is what keeps
171+
// a late browser locked out. Pinning this so nobody builds on reading it back.
172+
dbChainMockFns.returning.mockResolvedValueOnce([
173+
{
174+
toolCallId: 'workflow-tool',
175+
status: 'running',
176+
claimedBy: 'sim-stream',
177+
},
178+
])
179+
180+
const result = await markAsyncToolRunning('workflow-tool', 'sim-stream')
181+
182+
expect(result).toMatchObject({ claimedBy: 'sim-stream' })
183+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
184+
expect.objectContaining({ claimedBy: 'sim-stream' })
185+
)
186+
expect(getClaimedWorkflowExecutionId('sim-stream')).toBeUndefined()
187+
})
188+
165189
it('releases a matching pre-start workflow claim without changing its lifecycle status', async () => {
166190
dbChainMockFns.returning.mockResolvedValueOnce([
167191
{

apps/sim/lib/copilot/constants.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ export const TOOL_WATCHDOG_RESUME_GRACE_MS = 30_000
3636
/** Timeout for the client-side streaming response handler (60 min). */
3737
export const STREAM_TIMEOUT_MS = 3_600_000
3838

39+
/**
40+
* How long a workflow tool call waits for a browser to pick it up before the
41+
* server runs it itself.
42+
*
43+
* Workflow tools are client-routed, but the only thing that starts one is the
44+
* mounted chat view — a call frame that arrives while the user is on a
45+
* different chat is never dispatched by anyone, and the turn used to park for
46+
* the full STREAM_TIMEOUT_MS. The real pickup path (stream frame -> execute
47+
* POST -> claim) lands in ~1-3s, so 30s is an order of magnitude of headroom
48+
* and cannot steal work from a live tab.
49+
*/
50+
export const COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS = 30_000
51+
3952
/** SessionStorage key for persisting active stream metadata across page reloads. */
4053
export const STREAM_STORAGE_KEY = 'copilot_active_stream'
4154

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

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,16 @@ const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApprov
1515
})
1616
)
1717

18-
const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi.hoisted(() => ({
18+
const {
19+
upsertAsyncToolCall,
20+
markAsyncToolRunning,
21+
completeAsyncToolCall,
22+
claimWorkflowToolExecution,
23+
} = vi.hoisted(() => ({
1924
upsertAsyncToolCall: vi.fn(),
2025
markAsyncToolRunning: vi.fn(),
2126
completeAsyncToolCall: vi.fn(),
27+
claimWorkflowToolExecution: vi.fn().mockResolvedValue(null),
2228
}))
2329

2430
const { waitForClientToolCompletion, waitForToolCompletion, waitForWorkflowToolCompletion } =
@@ -56,6 +62,7 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({
5662
upsertAsyncToolCall,
5763
markAsyncToolRunning,
5864
completeAsyncToolCall,
65+
claimWorkflowToolExecution,
5966
}))
6067

6168
vi.mock('@/lib/copilot/request/tools/client', () => ({
@@ -578,11 +585,13 @@ describe('sse-handlers tool lifecycle', () => {
578585
await sleep(0)
579586
await Promise.allSettled(context.pendingToolPromises.values())
580587

588+
// The waiter always receives a signal now: the server fallback needs a
589+
// handle to cancel its own wait if it ends up running the tool itself.
581590
expect(waitForWorkflowToolCompletion).toHaveBeenCalledWith({
582591
toolCallId: 'tool-background',
583592
workflowId: 'workflow-1',
584593
timeoutMs: 1000,
585-
abortSignal: undefined,
594+
abortSignal: expect.any(AbortSignal),
586595
registry: execContext.resolvedSecretTraceRegistry,
587596
})
588597
expect(onEvent).toHaveBeenCalledWith(
@@ -644,6 +653,116 @@ describe('sse-handlers tool lifecycle', () => {
644653
)
645654
})
646655

656+
it('runs a workflow tool server-side when no browser picks it up', async () => {
657+
// Nobody claims it, the wait expires, and the server wins the claim.
658+
waitForWorkflowToolCompletion.mockResolvedValue(null)
659+
claimWorkflowToolExecution.mockResolvedValueOnce({ toolCallId: 'tool-unclaimed' })
660+
executeTool.mockResolvedValueOnce({ success: true, output: { ran: 'on-server' } })
661+
const onEvent = vi.fn()
662+
663+
await sseHandlers.tool(
664+
{
665+
type: MothershipStreamV1EventType.tool,
666+
payload: {
667+
toolCallId: 'tool-unclaimed',
668+
toolName: 'run_workflow',
669+
arguments: { workflowId: 'workflow-1' },
670+
executor: MothershipStreamV1ToolExecutor.client,
671+
mode: MothershipStreamV1ToolMode.async,
672+
phase: MothershipStreamV1ToolPhase.call,
673+
},
674+
} satisfies StreamEvent,
675+
context,
676+
execContext,
677+
{ onEvent, interactive: true, timeout: 1 }
678+
)
679+
680+
await Promise.allSettled(context.pendingToolPromises.values())
681+
682+
// Regression guard: the wait sets the call to 'executing' before parking,
683+
// and executeToolAndReport short-circuits anything already 'executing'. If
684+
// the handoff stops resetting the status, executeTool is never reached and
685+
// the workflow silently does not run.
686+
expect(executeTool).toHaveBeenCalled()
687+
expect(executeTool.mock.calls.at(-1)?.[0]).toBe('run_workflow')
688+
// The claimed execution id must reach the handler so the run is attributable.
689+
expect(executeTool.mock.calls.at(-1)?.[2]?.boundWorkflowExecutionId).toBeTruthy()
690+
691+
const workflowResults = onEvent.mock.calls
692+
.map(([event]) => event)
693+
.filter(
694+
(event) =>
695+
event?.type === MothershipStreamV1EventType.tool &&
696+
event.payload?.toolCallId === 'tool-unclaimed' &&
697+
event.payload?.phase === MothershipStreamV1ToolPhase.result
698+
)
699+
// Exactly one result, from the sim path — no client-flavored duplicate on top.
700+
expect(workflowResults).toHaveLength(1)
701+
expect(workflowResults[0].payload.executor).toBe(MothershipStreamV1ToolExecutor.sim)
702+
})
703+
704+
it('claims and runs the same workflow when the call omits an explicit workflowId', async () => {
705+
// The waiter resolves the target via resolveWorkflowToolTargetId(args, ctx)
706+
// while the handler resolves it as params.workflowId || context.workflowId.
707+
// If those two ever diverge, the fallback would claim one workflow and run
708+
// another.
709+
waitForWorkflowToolCompletion.mockResolvedValue(null)
710+
claimWorkflowToolExecution.mockResolvedValueOnce({ toolCallId: 'tool-implicit-workflow' })
711+
executeTool.mockResolvedValueOnce({ success: true, output: {} })
712+
713+
await sseHandlers.tool(
714+
{
715+
type: MothershipStreamV1EventType.tool,
716+
payload: {
717+
toolCallId: 'tool-implicit-workflow',
718+
toolName: 'run_workflow',
719+
arguments: {},
720+
executor: MothershipStreamV1ToolExecutor.client,
721+
mode: MothershipStreamV1ToolMode.async,
722+
phase: MothershipStreamV1ToolPhase.call,
723+
},
724+
} satisfies StreamEvent,
725+
context,
726+
execContext,
727+
{ onEvent: vi.fn(), interactive: true, timeout: 1 }
728+
)
729+
730+
await Promise.allSettled(context.pendingToolPromises.values())
731+
732+
expect(waitForWorkflowToolCompletion).toHaveBeenCalledWith(
733+
expect.objectContaining({ workflowId: 'workflow-1' })
734+
)
735+
expect(executeTool.mock.calls.at(-1)?.[2]?.workflowId).toBe('workflow-1')
736+
})
737+
738+
it('does not run a workflow tool server-side when a browser holds the claim', async () => {
739+
waitForWorkflowToolCompletion.mockResolvedValue(null)
740+
claimWorkflowToolExecution.mockResolvedValueOnce(null)
741+
executeTool.mockClear()
742+
const onEvent = vi.fn()
743+
744+
await sseHandlers.tool(
745+
{
746+
type: MothershipStreamV1EventType.tool,
747+
payload: {
748+
toolCallId: 'tool-claimed-elsewhere',
749+
toolName: 'run_workflow',
750+
arguments: { workflowId: 'workflow-1' },
751+
executor: MothershipStreamV1ToolExecutor.client,
752+
mode: MothershipStreamV1ToolMode.async,
753+
phase: MothershipStreamV1ToolPhase.call,
754+
},
755+
} satisfies StreamEvent,
756+
context,
757+
execContext,
758+
{ onEvent, interactive: true, timeout: 1 }
759+
)
760+
761+
await Promise.allSettled(context.pendingToolPromises.values())
762+
763+
expect(executeTool).not.toHaveBeenCalled()
764+
})
765+
647766
it('waits for the desktop client when a static VFS read is explicitly user-local', async () => {
648767
waitForClientToolCompletion.mockResolvedValueOnce({
649768
status: 'success',

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

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import { isBrowserToolName } from '@sim/browser-protocol'
22
import { createLogger } from '@sim/logger'
33
import { isTerminalToolName } from '@sim/terminal-protocol'
44
import { getErrorMessage, toError } from '@sim/utils/errors'
5-
import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle'
5+
import type {
6+
AsyncCompletionSignal,
7+
AsyncTerminalCompletionSnapshot,
8+
} from '@/lib/copilot/async-runs/lifecycle'
69
import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository'
7-
import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants'
10+
import { COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS, STREAM_TIMEOUT_MS } from '@/lib/copilot/constants'
811
import {
912
MothershipStreamV1AsyncToolRecordStatus,
1013
type MothershipStreamV1ToolCallDescriptor,
@@ -24,17 +27,15 @@ import {
2427
} from '@/lib/copilot/request/session'
2528
import { markToolResultSeen, wasToolResultSeen } from '@/lib/copilot/request/sse-utils'
2629
import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state'
27-
import {
28-
waitForClientToolCompletion,
29-
waitForWorkflowToolCompletion,
30-
} from '@/lib/copilot/request/tools/client'
30+
import { waitForClientToolCompletion } from '@/lib/copilot/request/tools/client'
3131
import { sealClientToolContext } from '@/lib/copilot/request/tools/client-completion-seal.server'
3232
import { executeToolAndReport } from '@/lib/copilot/request/tools/executor'
3333
import {
3434
runGatedToolExecution,
3535
TOOL_AWAITING_APPROVAL_STATUS,
3636
toolCallNeedsApproval,
3737
} from '@/lib/copilot/request/tools/permission'
38+
import { raceWorkflowToolClientPickup } from '@/lib/copilot/request/tools/workflow-client-fallback'
3839
import type {
3940
ExecutionContext,
4041
OrchestratorOptions,
@@ -671,9 +672,11 @@ async function dispatchToolExecution(
671672
): Promise<void> {
672673
const scopeLabel = scope === 'subagent' ? 'subagent ' : ''
673674

674-
const fireToolExecution = (): Promise<AsyncCompletionSignal> => {
675+
const fireToolExecution = (
676+
execContextOverride?: ExecutionContext
677+
): Promise<AsyncCompletionSignal> => {
675678
return (async () => {
676-
return executeToolAndReport(toolCallId, context, execContext, options)
679+
return executeToolAndReport(toolCallId, context, execContextOverride ?? execContext, options)
677680
})().catch((err) => {
678681
logger.error(`Parallel ${scopeLabel}tool execution failed`, {
679682
toolCallId,
@@ -754,23 +757,58 @@ async function dispatchToolExecution(
754757
...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}),
755758
},
756759
async (span) => {
757-
const completion = isWorkflowToolName(toolName)
758-
? await waitForWorkflowToolCompletion({
759-
toolCallId,
760-
workflowId: resolveWorkflowToolTargetId(args, execContext.workflowId),
761-
timeoutMs: timeoutMs ?? STREAM_TIMEOUT_MS,
762-
abortSignal: options.abortSignal,
763-
registry: execContext.resolvedSecretTraceRegistry,
764-
})
765-
: await waitForClientToolCompletion({
766-
toolCallId,
767-
runId: context.runId,
768-
userId: execContext.userId,
769-
timeoutMs,
770-
abortSignal: options.abortSignal,
771-
registry: execContext.resolvedSecretTraceRegistry,
772-
})
773-
span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== undefined)
760+
let completion: AsyncTerminalCompletionSnapshot | null
761+
if (isWorkflowToolName(toolName)) {
762+
const race = await raceWorkflowToolClientPickup({
763+
toolCallId,
764+
workflowId: resolveWorkflowToolTargetId(args, execContext.workflowId),
765+
timeoutMs: timeoutMs ?? STREAM_TIMEOUT_MS,
766+
graceMs: COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS,
767+
abortSignal: options.abortSignal,
768+
registry: execContext.resolvedSecretTraceRegistry,
769+
runOnServer: (boundExecutionId) => {
770+
// `executeToolAndReportInner` short-circuits a call that is
771+
// already 'executing' — which is exactly what this wait set it to
772+
// before parking. Hand it back the state it dispatches from.
773+
toolCall.status = 'pending'
774+
return fireToolExecution({
775+
...execContext,
776+
boundWorkflowExecutionId: boundExecutionId,
777+
})
778+
},
779+
})
780+
781+
if (race.winner === 'sim') {
782+
// `executeToolAndReport` already emitted its own `executor: sim`
783+
// result and marked it seen, so the client-completion bookkeeping
784+
// below must not run again on top of it.
785+
span.setAttribute(TraceAttr.ToolExecutor, MothershipStreamV1ToolExecutor.sim)
786+
if (race.signal) {
787+
span.setAttribute(TraceAttr.ToolOutcome, race.signal.status)
788+
}
789+
return (
790+
race.signal ?? {
791+
status: MothershipStreamV1ToolOutcome.error,
792+
message: 'Tool completion missing',
793+
data: { error: 'Tool completion missing' },
794+
}
795+
)
796+
}
797+
completion = race.completion ?? null
798+
} else {
799+
completion = await waitForClientToolCompletion({
800+
toolCallId,
801+
runId: context.runId,
802+
userId: execContext.userId,
803+
timeoutMs,
804+
abortSignal: options.abortSignal,
805+
registry: execContext.resolvedSecretTraceRegistry,
806+
})
807+
}
808+
span.setAttribute(TraceAttr.ToolExecutor, MothershipStreamV1ToolExecutor.client)
809+
// Both waiters resolve `T | null`, never undefined — comparing against
810+
// undefined made this a constant `true` and hid every timeout.
811+
span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== null)
774812
if (completion) {
775813
span.setAttribute(TraceAttr.ToolOutcome, completion.status)
776814
}

0 commit comments

Comments
 (0)