Skip to content

Commit 8224475

Browse files
committed
fix(desktop): scope the overdue sweep to desktop calls and keep ungated pickup windows
- The overdue listing and the per-call deadline read only consider desktop tool calls, and the settlement checks the call is one the desktop runs, so a bound run's workflow call or Sim-files VFS read is never failed with the desktop's not-started result. - The cron scans only runs inside the inbox's horizon, oldest calls first, within its batch limit. - Recording a decision clears the pickup deadline only for a call that was gated; a call that was never gated keeps the window it was offered with.
1 parent 5156261 commit 8224475

5 files changed

Lines changed: 79 additions & 6 deletions

File tree

‎apps/sim/lib/desktop/executor/bound-turn.integration.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,62 @@ describe.runIf(Boolean(redisUrl))("a turn bound to a desktop's background execut
731731
TURN_WAIT_MS
732732
)
733733

734+
it(
735+
'leaves a bound run’s calls the desktop never runs to their own path',
736+
async () => {
737+
const desktop = await signedInDesktop()
738+
const run = await boundRun(desktop)
739+
const longAgo = new Date(Date.now() - 600_000)
740+
const workflowCall = generateId()
741+
const simFileRead = generateId()
742+
await db.insert(copilotAsyncToolCalls).values([
743+
{
744+
runId: run.runId,
745+
toolCallId: workflowCall,
746+
toolName: 'run_workflow',
747+
args: {},
748+
createdAt: longAgo,
749+
},
750+
{
751+
runId: run.runId,
752+
toolCallId: simFileRead,
753+
toolName: 'read',
754+
args: { path: 'workspace/notes.md' },
755+
createdAt: longAgo,
756+
},
757+
])
758+
759+
await runCleanupStaleExecutions()
760+
761+
expect((await storedCall(workflowCall)).status).toBe('pending')
762+
expect((await storedCall(simFileRead)).status).toBe('pending')
763+
},
764+
TURN_WAIT_MS
765+
)
766+
767+
it(
768+
'keeps the pickup window of a call that was never gated when a decision is posted for it',
769+
async () => {
770+
const desktop = await signedInDesktop()
771+
const run = await boundRun(desktop)
772+
await desktop.pull()
773+
774+
const { toolCallId, answer, context } = await agentCalls(run, 'browser_click', { ref: 'e1' })
775+
const offeredRow = await offered(toolCallId)
776+
const decided = await post(toolPermissionPOST, '/api/copilot/tool-permission', {
777+
decisions: [{ toolCallId, decision: 'allow' }],
778+
})
779+
expect(decided.status).toBe(200)
780+
781+
expect((await storedCall(toolCallId)).pickupDeadlineAt).toEqual(offeredRow.pickupDeadlineAt)
782+
const { executionToken } = await desktop.claim(toolCallId)
783+
await desktop.complete(toolCallId, executionToken, { clicked: true })
784+
await answer
785+
expect(resultOf(context, toolCallId)).toMatchObject({ success: true })
786+
},
787+
TURN_WAIT_MS
788+
)
789+
734790
it(
735791
'settles a call Sim never offered once its pickup window would have closed',
736792
async () => {

‎apps/sim/lib/desktop/executor/repository.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -336,12 +336,14 @@ export async function offerDesktopToolCall(input: {
336336
/**
337337
* A bound desktop call with the deadlines only Sim enforces, read on the database clock every CAS
338338
* uses: whether its pickup window closed while it is unclaimed, and whether its lease lapsed while
339-
* it runs. Null for a call on a run no device is bound to.
339+
* it runs. Null for a call on a run no device is bound to, and for a tool the desktop never runs.
340340
*/
341341
export async function getDesktopToolCallDeadlines(toolCallId: string) {
342342
const [row] = await db
343343
.select({
344344
toolCallId: copilotAsyncToolCalls.toolCallId,
345+
toolName: copilotAsyncToolCalls.toolName,
346+
args: copilotAsyncToolCalls.args,
345347
runId: copilotRuns.id,
346348
userId: copilotRuns.userId,
347349
deviceId: copilotRuns.desktopDeviceId,
@@ -361,7 +363,11 @@ export async function getDesktopToolCallDeadlines(toolCallId: string) {
361363
.innerJoin(copilotRuns, eq(copilotRuns.id, copilotAsyncToolCalls.runId))
362364
.leftJoin(desktopDevices, eq(desktopDevices.id, copilotRuns.desktopDeviceId))
363365
.where(
364-
and(eq(copilotAsyncToolCalls.toolCallId, toolCallId), isNotNull(copilotRuns.desktopDeviceId))
366+
and(
367+
eq(copilotAsyncToolCalls.toolCallId, toolCallId),
368+
isNotNull(copilotRuns.desktopDeviceId),
369+
inArray(copilotAsyncToolCalls.toolName, [...DESKTOP_TOOL_CALL_NAMES])
370+
)
365371
)
366372
.limit(1)
367373
return row?.deviceId ? { ...row, deviceId: row.deviceId } : null
@@ -374,7 +380,8 @@ export type DesktopToolCallDeadlines = NonNullable<
374380
/**
375381
* Bound desktop calls a deadline passed for at least `slackMs` ago: unclaimed past their pickup
376382
* deadline (offered or not), or claimed by the executor with a lapsed lease. A live waiter settles
377-
* these within its 5 s poll, so anything this finds lost its waiter.
383+
* these within its 5 s poll, so anything this finds lost its waiter. Only runs inside the inbox's
384+
* horizon are scanned, oldest calls first.
378385
*/
379386
export async function listOverdueDesktopToolCalls(input: { slackMs: number; limit: number }) {
380387
const overdue = sql`clock_timestamp() - ${input.slackMs} * interval '1 millisecond'`
@@ -385,6 +392,8 @@ export async function listOverdueDesktopToolCalls(input: { slackMs: number; limi
385392
.where(
386393
and(
387394
isNotNull(copilotRuns.desktopDeviceId),
395+
sql`${copilotRuns.startedAt} > now() - make_interval(hours => ${DESKTOP_INBOX_HORIZON_HOURS})`,
396+
inArray(copilotAsyncToolCalls.toolName, [...DESKTOP_TOOL_CALL_NAMES]),
388397
or(
389398
pickupOverdueAt(overdue),
390399
and(
@@ -397,6 +406,7 @@ export async function listOverdueDesktopToolCalls(input: { slackMs: number; limi
397406
)
398407
)
399408
)
409+
.orderBy(asc(copilotAsyncToolCalls.createdAt), asc(copilotAsyncToolCalls.toolCallId))
400410
.limit(input.limit)
401411
return rows.map((row) => row.toolCallId)
402412
}

‎apps/sim/lib/mothership/async-runs/repository.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1738,8 +1738,9 @@ export async function recordToolPermissionDecision(
17381738
.set({
17391739
permissionDecision: decision,
17401740
permissionDecidedAt: now,
1741-
// No pickup window runs while the user decides: an allowed call is offered afresh.
1742-
pickupDeadlineAt: null,
1741+
// No pickup window runs while the user decides: a gated call is offered afresh once
1742+
// allowed. A call that was never gated keeps the window it was offered with.
1743+
pickupDeadlineAt: sql`CASE WHEN ${copilotAsyncToolCalls.permissionRequestedAt} IS NULL THEN ${copilotAsyncToolCalls.pickupDeadlineAt} END`,
17431744
updatedAt: now,
17441745
})
17451746
.where(

‎apps/sim/lib/mothership/request/tools/desktop-wait.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ describe('settleAbandonedDesktopToolCalls', () => {
9898
.mockRejectedValueOnce(new Error('connection reset'))
9999
.mockResolvedValueOnce({
100100
toolCallId: 'overdue',
101+
toolName: 'browser_click',
102+
args: { ref: 'e1' },
101103
runId: 'run-1',
102104
userId: 'user-1',
103105
deviceId: 'device-1',

‎apps/sim/lib/mothership/request/tools/desktop-wait.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { interruptibleSleep } from '@sim/utils/helpers'
4+
import { toRecordOrNull } from '@sim/utils/object'
45
import { ringDesktopInbox } from '@/lib/desktop/executor/doorbell'
56
import { isDesktopPresent } from '@/lib/desktop/executor/presence'
67
import {
@@ -23,6 +24,7 @@ import {
2324
type ClientToolSettlementGuard,
2425
settleClientToolCall,
2526
} from '@/lib/mothership/request/tools/client-settlement.server'
27+
import { isDesktopToolCall } from '@/lib/mothership/tools/desktop-tools'
2628
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
2729

2830
const logger = createLogger('CopilotDesktopToolWait')
@@ -230,7 +232,9 @@ async function readDesktopPresence(deviceId: string): Promise<boolean | null> {
230232
*/
231233
async function settleOverdueDesktopToolCall(toolCallId: string): Promise<boolean> {
232234
const call = await getDesktopToolCallDeadlines(toolCallId)
233-
if (!call) return false
235+
// A VFS read of Sim's own files shares its tool name with a local read, but no desktop runs it.
236+
if (!call || !isDesktopToolCall(call.toolName, toRecordOrNull(call.args) ?? undefined))
237+
return false
234238
if (call.status === ASYNC_TOOL_STATUS.pending) {
235239
const present = await readDesktopPresence(call.deviceId)
236240
// Before its pickup window closes, a call fails early only when its device is known to be away:

0 commit comments

Comments
 (0)