Skip to content

feat(desktop): bind turns to a desktop and enforce its deadlines from the row - #8650

Open
waleedlatif1 wants to merge 4 commits into
stagingfrom
feat/desktop-executor-binding
Open

waleedlatif1 wants to merge 4 commits into
stagingfrom
feat/desktop-executor-binding

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Second of three Phase 1 PRs for the desktop background executor. It connects the run loop to the device protocol from #8644. It stays inert until a desktop that speaks the protocol registers with mothership-desktop-background-executor on: no shipped desktop build sends a device id, so no run is bound.

Built on #8643, #8652 and #8644, all on staging.

What changed in this rework

Final-review fixes

  • Stop with an executor-held call no longer waits for the device (B1). The executor's claim marked a Sim execution as started (execution_started_at), but Stop cancels the row without settling that execution, so areStreamToolExecutionsSettled stayed false until the device acknowledged. A device that was asleep, offline or signed out never would: abortRun answered settled:false and the next turn's workbench reported handlersPending. The executor's claim now takes only its owner token and lease. No Sim handler runs for it, so Sim-execution quiescence ignores it, exactly as it already ignored the chat view's desktop claims; the executor keeps its own token, lease, settled and revoked fences. Autopsy: every earlier Stop test had the device acknowledge (post its late result) before anything checked settlement, so the unacknowledged path was never exercised.
  • Deadlines and the inbox no longer depend on Redis (B2). An overdue unclaimed call settles from its row even when presence can't be read: not_responding, and a failed read never fails a call early as offline. Each call in the cron's sweep is settled in its own try/catch, and presence writes are best effort, so a Redis error no longer fails a pull or a renewal.
  • Only offered calls are claimable. The executor takes a call only inside its offer's pickup window, and the inbox lists unclaimed calls only while they are offered or waiting for the user. A call Sim never got to offer (its process died first) gets an implicit deadline one pickup window after it could first run, so the cron still settles it.
  • Smaller fixes:
    • a revoked install id stays revoked: registering it again is refused rather than clearing the revocation;
    • a claim that races the device binding answers "no longer waiting" instead of a 500;
    • Stop rings the device only after the run's chat is validated;
    • one device lookup, with an executor option, replaces the two near-identical ones;
    • scripts/test-desktop-inbox-e2e.ts runs in CI: test:desktop-inbox:e2e, in the http-e2e job, against its own app with a Redis service.

What this PR does

  • Binding at admission. The composer adds deviceId and executor to desktopCapabilities only when the shell exposes a registered background executor (SimDesktopApi.desktopExecutor.getDevice(), an optional bridge field that Phase 2's preload will provide). admitChatTurn writes copilot_runs.desktop_device_id only when the flag is on for the user and the device is registered to this user and the caller's own session, is not revoked, and advertises executor >= 1. Otherwise the turn stays with the chat view. Assistant turns, turns with every desktop surface off, and web turns never bind (product decision 1).

  • Every desktop call on a bound run is persisted pending, local reads included, and is claimed only by the device's executor. /api/desktop/tool/authorize and /api/copilot/confirm answer 409 for a bound run's desktop calls, so a second window or an older desktop showing the same chat can neither run them nor fail them.

  • Deadlines, all read from the row on the database clock (pickup_deadline_at is migration 0398):

    • an unclaimed call whose device is not present fails at once as {notStarted:true, reason:'offline'}; the turn continues without desktop tools (product decision 3);
    • an unclaimed call still pending when its pickup window closes fails as {notStarted:true, reason:'not_responding'};
    • a claimed call whose lease lapsed fails as {outcomeUnknown:true, doNotRetry:true}, revoking the device's token (the fence gains a lapsed lease mode, so a renewal wins), and the device is rung to cancel it.

    Each is a CAS against the device's own transitions (the not-started one is the inverse of the claim), sealed like the device's result, and published so the waiter wakes at once. A call whose pickup window closed is no longer listed or claimable, even before the wait's next check settles it. The generic Sim lease sweep leaves a desktop executor's lease alone: its lapse is settled here, as outcome unknown, rather than with the Sim tool's "interrupted" result.

  • Restart. Replay never re-dispatches a tool call, so a waiter that dies with its process is not resumed. cleanup-stale-executions settles bound calls overdue by more than 60 s the same way, sealed, so the resumed run restores them.

  • Doorbells. The device is rung when a bound call needs approval, when the user answers (/api/copilot/tool-permission), when a call is offered, when a lease lapses, and on Stop (abortRun).

  • Budgets. The resume gate gives a bound desktop call the full client budget, since its deadlines settle it first; a long terminal command lives as long as the device renews its lease.

Test plan

  • lib/desktop/executor/bound-turn.integration.ts (real PostgreSQL and Redis), 9 tests, driving the production path prePersist → sseHandlers.tool → device claim/renew/complete:
    • binding: only an executor registered to this very session, and only with the flag on;
    • a present device is offered the call, rung, claims it and its result reaches the turn; the chat view's authorize gets 409;
    • a bound turn's local read is handed to the executor too;
    • an offline device: not started (offline) in one durable check, and a late claim is refused;
    • a closed pickup window: not started (not_responding);
    • a closed pickup window: the inbox drops the call and the device's claim is refused before the wait settles it;
    • a renewed lease keeps a call running past its pickup deadline; a lapsed one survives the generic Sim lease sweep, then gives outcome unknown, a cancel ring, a superseded late result, and a 410 on renewal;
    • approval: approval ring on the gated call, another on the answer through the real tool-permission route, then the offer;
    • Stop through the real requestRunStop: the turn gets the stop result, renewal is refused, the inbox lists cancel;
    • the durable path when a waiter is gone: the run loop is aborted mid-offer and mid-lease (the in-process waiter stops; nothing in memory survives to settle the calls), then the real runCleanupStaleExecutions settles both from their rows alone, and a resumed waiter restores the sealed not-started and outcome-unknown results. This is the row-only path a restarted process relies on, not a fresh-module restart;
    • a call Sim never offered is settled by the cron once its implicit pickup window passed;
    • Stop with the call held and no acknowledgement: the turn's tool executions read as settled, and the chat's next turn gets its workbench (handlersPending: false);
    • presence that cannot be read: the call is not failed early, and still fails as not_responding once its window closes.
  • Unit: authorize and confirm refuse a bound run's call (409); Stop rings a bound device; the composer offers a device only when the shell has a registered executor; the resume-gate budget; a bound call's watchdog reason.
  • Red before the fix: 15 guards reverted one at a time against bound-turn.integration.ts. 14 turn it red: dispatch ignoring the binding, no settleOverdue, offline not failed fast, a closed pickup window ignored, no cancel ring on lapse, no ring on offer, unsealed settlements, no approval ring, no ring on the user's answer, chat-view claim allowed, cron not settling, binding ignoring the flag, binding accepting a non-executor, and bound turns not claiming local reads. The survivor is the early leaseLapsed read, which sits in front of the SQL lapsed fence (lease <= clock_timestamp()); that fence still refuses the write, so the read is only a shortcut.
  • feat(desktop): device registry, inbox and leased claims for a background executor #8644's, fix(mothership): refuse desktop claims for unapproved or stopped calls #8643's and fix(mothership): fail unclaimed desktop calls fast and stop dropping them silently #8652's suites still pass on this branch: every integration file under lib/desktop, lib/mothership/tools/client and lib/mothership/async-runs (91 tests). browser-download-claim.integration.ts hand-builds the tool-call table, so it gains the new column.
  • Gate: lint, type-check, check:audits, check:migrations (0397, 0398), docs-manifest:check, block registry, drizzle-kit generate (no drift), and bun run test (37,235 passed).

Notes

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@vercel

vercel Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
docs Skipped Skipped Oct 6, 2026 6:32am UTC

Request Review

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 18 files

Fix all with cubic | Re-trigger cubic

Comment thread apps/sim/lib/desktop/index.ts
Comment thread apps/sim/lib/desktop/executor/supervisor.integration.ts Outdated
@greptile-apps

greptile-apps Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[High risk] Binds turns to a desktop device and adds database schema for deadlines.

The PR appears safe to merge based on this review; no outstanding finding or new actionable issue remains.

Summary

The PR binds eligible chat turns to a registered desktop executor and enforces desktop-tool pickup and lease deadlines from persisted rows. Since the previous review, it replaces mock-call assertions with outcome checks and exercises Stop through the authorized run-control path. No new actionable issue was established; the previous findings are resolved or withdrawn.

Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Admit turn] --> B{Registered executor?}
  B -- No --> C[Chat-view desktop handling]
  B -- Yes --> D[Bind run to device]
  D --> E[Persist and offer desktop call]
  E --> F{Device claims in pickup window?}
  F -- No --> G[Settle as not started]
  F -- Yes --> H{Lease renewed or result received?}
  H -- Result --> I[Settle device result]
  H -- Lease lapses --> J[Revoke token and settle outcome unknown]
  E --> K[Stale-execution sweep]
  K --> G
  K --> J
Loading

Reviews (10) · Last reviewed commit: "test(desktop): assert outcomes instead o..."

Comment thread apps/sim/lib/desktop/executor/supervisor.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 18 files

Fix all with cubic | Re-trigger cubic

Comment thread apps/sim/lib/mothership/request/tools/executor.ts Outdated
@waleedlatif1
waleedlatif1 force-pushed the feat/desktop-executor-binding branch from e152d13 to c931789 Compare October 6, 2026 00:32
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 19 files

Fix all with cubic | Re-trigger cubic

Comment thread apps/sim/lib/desktop/executor/supervisor.ts Outdated
Comment thread apps/sim/lib/desktop/executor/repository.ts Outdated
@waleedlatif1
waleedlatif1 force-pushed the feat/desktop-executor-binding branch from c931789 to 4d32e76 Compare October 6, 2026 00:47
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 19 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

@waleedlatif1
waleedlatif1 force-pushed the feat/desktop-executor-binding branch from 4d32e76 to 3620c2a Compare October 6, 2026 01:02
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 19 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.

Re-trigger cubic

Comment thread apps/sim/lib/desktop/executor/supervisor.integration.ts Outdated
@waleedlatif1
waleedlatif1 force-pushed the feat/desktop-executor-binding branch from 3620c2a to b6972e7 Compare October 6, 2026 01:11
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 33 files

You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.

Fix all with cubic | Re-trigger cubic

Comment thread apps/sim/lib/mothership/request/tools/desktop-wait.ts Outdated
@greptile-apps

This comment has been minimized.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Both outside-diff findings were real; fixed in a31aaa8, each with a regression test that fails without the fix.

  • Expired calls remain claimable: claimDesktopToolCall and the inbox now treat an offered call whose pickup_deadline_at has passed as no longer offered. A device that claims in that window gets 404 before the wait's next check settles the call as not_responding.
  • Generic sweep preempts desktop settlement: revokeExpiredExecutions now skips calls held by a desktop executor's claim owner, so a co-running Sim waiter can no longer replace the structured outcomeUnknown/doNotRetry result. That lapse is settled by the bound wait's durable check and, if the waiter died, by the stale-execution cron. The restart test covers the cron path.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 33 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.

Re-trigger cubic

… the row

A turn sent from a desktop whose background executor is registered to the
same session (and with mothership-desktop-background-executor on) is bound
to that device at admission: copilot_runs.desktop_device_id. Its desktop
calls are persisted pending, offered to the device and claimed through the
executor's own fenced routes; the chat view's authorize and confirm answer
409 for them.

There is no supervisor. The single desktop wait (waitForDesktopToolCall)
branches on the binding: a bound call is offered (pickup_deadline_at, a new
nullable column) and the device's doorbell rung, then the existing durable
wait enforces every deadline from the row on each 5 s check, beside the
lease revocation that already lives there:
- an unclaimed call whose device is offline fails at once as not started
  (reason offline); one still unclaimed past its pickup window fails the same
  way (reason not_responding), as the inverse CAS of the claim;
- a claimed call whose lease lapsed fails as outcome unknown, revoking the
  device's token so its late result is superseded, and rings it to cancel.
Every settlement is sealed like the device's own result. The stale-execution
cron settles, the same way, bound calls whose waiter died with its process.

One not-started builder carries a reason (chat_not_open, offline,
not_responding), and the server-owned failure helper now settles through the
shared client settlement. The device is rung when a bound call needs
approval, when the user answers, and on Stop.
…r leases to their own settlement

A device could claim an offered call after its pickup deadline and before the wait's next check settled it, running an action the turn was about to report as not started. The claim and the inbox now treat a closed window as no longer offered. The generic Sim lease sweep no longer settles a desktop executor's lapsed lease with the Sim interrupted result: the bound wait and the stale-execution cron settle it as outcome unknown, so the model is told the action may already have taken effect.
…nd only run offered calls

Stop on a call the background executor held never settled the turn's tool
executions unless the device acknowledged: the executor's claim marked a Sim
execution as started, and Stop does not settle that execution. A device that
was asleep, offline or signed out left abortRun unsettled and the next turn's
workbench pending. The executor's claim now takes only its owner token and
lease; no Sim handler runs for it, so Sim-execution quiescence ignores it, as
it already ignores the chat view's desktop claims.

An overdue unclaimed call now settles from its row when presence cannot be
read, and never fails early as offline on a failed read; each call in the
cron's sweep settles in its own try/catch; presence writes are best effort, so
a Redis error no longer fails a pull or a renewal.

The executor takes only a call Sim offered it, within its pickup window, and
the inbox lists unclaimed calls only while offered or waiting for the user. A
call Sim never got to offer gets an implicit deadline one pickup window after
it could first run, so the cron still settles it.

A revoked install id stays revoked on re-registration, a claim racing the
device binding answers "no longer waiting", Stop rings the device only after
its chat is validated, the two device lookups are one, and the desktop inbox
E2E runs in the http-e2e CI job against its own app with Redis.
@waleedlatif1
waleedlatif1 changed the base branch from feat/desktop-executor-foundation to staging October 6, 2026 06:11
@waleedlatif1
waleedlatif1 force-pushed the feat/desktop-executor-binding branch from a31aaa8 to 72a5ba3 Compare October 6, 2026 06:11
@waleedlatif1
waleedlatif1 requested a review from a team as a code owner October 6, 2026 06:11
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

Comment thread apps/sim/app/api/copilot/confirm/route.test.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 39 files

Confidence score: 3/5

  • In presence.ts, a failed heartbeat can leave an awake device’s pending call reported as notStarted when a later EXISTS finds no key. Preserve an unknown state after heartbeat failures instead of treating the missing key as offline.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/sim/lib/desktop/executor/presence.ts">

<violation number="1" location="apps/sim/lib/desktop/executor/presence.ts:31">
P2: Swallowing a failed heartbeat can make an awake device’s pending call settle as `notStarted`: a subsequent successful `EXISTS` sees no key, and the waiter treats `false` as offline. Preserve an unknown state for failed refreshes, or otherwise prevent that absence from taking the offline settlement path.</violation>
</file>

You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.

Fix all with cubic | Re-trigger cubic

if (!redis) return
try {
await redis.set(presenceKey(deviceId), '1', 'EX', DESKTOP_PRESENCE_TTL_SECONDS)
} catch (error) {

@cubic-dev-ai cubic-dev-ai Bot Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Swallowing a failed heartbeat can make an awake device’s pending call settle as notStarted: a subsequent successful EXISTS sees no key, and the waiter treats false as offline. Preserve an unknown state for failed refreshes, or otherwise prevent that absence from taking the offline settlement path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/desktop/executor/presence.ts, line 31:

<comment>Swallowing a failed heartbeat can make an awake device’s pending call settle as `notStarted`: a subsequent successful `EXISTS` sees no key, and the waiter treats `false` as offline. Preserve an unknown state for failed refreshes, or otherwise prevent that absence from taking the offline settlement path.</comment>

<file context>
@@ -15,11 +19,18 @@ export function isDesktopPresenceAvailable(): boolean {
+  if (!redis) return
+  try {
+    await redis.set(presenceKey(deviceId), '1', 'EX', DESKTOP_PRESENCE_TTL_SECONDS)
+  } catch (error) {
+    logger.warn('Could not record desktop presence', { deviceId, error: toError(error).message })
+  }
</file context>
Fix with cubic

…through abortRun

The desktop tests asserted that mocks were or were not called. They now assert what the caller sees: the 409, the sweep's settled count, and the device's own doorbell, which Stop now rings through the real abortRun against a stand-in worker.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 38 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.

Re-trigger cubic

This branch was previously deployed

1 inactive deployment
Preview — 48920e28 Deployed Oct 6, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant