Skip to content

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

Merged
waleedlatif1 merged 6 commits into
stagingfrom
feat/desktop-executor-binding
Oct 6, 2026
Merged

waleedlatif1 merged 6 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. Because a lost presence write would otherwise read as "offline", a call is failed early only when presence is absent and the device has not pulled for longer than the presence TTL plus the last_seen_at write interval (105 s); otherwise its pickup window decides.
  • 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.

Pre-merge review fixes

  • Terminal approval summary. Terminal calls carry their command at args.args.command ({ operation: 'run', args: { command } }); the inbox read args.command, so terminal approvals had no summary. The executor tests used the flat shape and masked it; they now use the real one, and the bound-turn test asserts the summary on a call persisted by the production path.
  • A turn budget shorter than the pickup window. The bound wait returned null and left the call pending; an unclaimed call is now settled as never started (not_responding) through fix(mothership): fail unclaimed desktop calls fast and stop dropping them silently #8652's pending-only path, as the chat-view branch does.
  • The doorbell can't break Stop. ringDesktopInbox is best effort: a publish that throws is logged and swallowed, for every caller (Stop, decisions, offers). A ring only hurries the device's next pull.
  • The inbox can't be starved. Running claimed calls are not inbox items, but they counted against the 500-row cap and could hide new work. They are no longer fetched, and offered/awaiting-approval calls and cancel items are fetched as two sets with their own caps.
  • No pickup window while the user decides. An offer now refuses a call still awaiting approval, and recording the user's decision clears any pickup deadline, so an allowed call's window starts when it is offered after the answer. Regression: a gated call left unanswered past a pickup window (with a stale deadline on its row) survives the cron, stays approval_needed, and once allowed is offered afresh and completes.
  • Rate-limit refill. The per-user bucket keeps its 600-request burst and 10/s sustained rate, but refills every second instead of in one lump a minute, so a device that spent its burst can still renew leases before they lapse.
  • Both desktop integration suites now delete the audit rows they create in their teardown.

Considered, no change

  • Per-tool capability at binding. A turn's desktop tools come from the surfaces the same desktop's composer declared (desktopCapabilities.browser/terminal/localFiles), so the model is not offered a tool family that desktop lacks. If an executor still could not run one, the call fails as not_responding after its 15 s pickup window rather than hanging. Per-family executor capabilities belong with the Phase 2 desktop protocol version.
  • Concurrent device streams. Not capped: each stream is a read-only doorbell subscription, rate-limited per user and ended at its session's next revalidation. Duplicate streams only deliver duplicate rings, which the inbox read makes idempotent.
  • Doorbell subscription race. Covered by the device's reconcile pull every 10 s, inside the 15 s pickup window: executor.integration.ts "lists pending calls in persistence order even when their doorbell was never heard", and the E2E check "reconciles a call whose doorbell was lost within its pickup window".

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 away (no presence, and no pull for over 105 s) 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; an awake device whose presence write was lost is not failed early, and its result is delivered;
    • 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.

Project Deployment Actions Updated
docs Ready Ready Preview Oct 6, 2026 4:42pm 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

[Critical risk] Binds tool execution to desktop devices and enforces deadlines.

The PR appears safe to merge based on the reviewed changes and resolved previous findings.

Summary

The PR binds eligible chat turns to a registered desktop executor and enforces pickup and lease deadlines from durable call rows. It also adds device inbox notifications, cancellation handling, and integration and HTTP E2E coverage.

  • Previously reported findings are resolved or withdrawn; no new actionable finding was identified.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Admit eligible turn] --> B[Bind registered desktop]
  B --> C[Persist desktop call]
  C --> D{Approval needed?}
  D -- Yes --> E[Wait for decision]
  E -- Allowed --> F[Offer with pickup deadline]
  D -- No --> F
  F --> G{Device claims in time?}
  G -- No --> H[Settle as not started]
  G -- Yes --> I[Run under renewable lease]
  I --> J{Result or lapsed lease?}
  J -- Result --> K[Settle device result]
  J -- Lapsed --> L[Settle outcome unknown and ring cancel]
Loading

Reviews (12) · Last reviewed commit: "fix(desktop): pre-merge review fixes for..."

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

@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

… 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.
…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.
…presence write was lost

Presence writes are best effort, so a pull whose write failed left the key
missing and the next read took the device for offline, failing its pending
call early. A call now fails early as offline only when presence is absent and
the device has not pulled for longer than the presence TTL plus the interval
at which a pull writes last_seen_at; otherwise its pickup window decides.

The audit test no longer depends on the insertion order of audit writes,
which are not awaited.
@waleedlatif1
waleedlatif1 force-pushed the feat/desktop-executor-binding branch from 48920e2 to 0a5f255 Compare October 6, 2026 15:52
@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.

@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 39 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 | Turn on auto-fix | Re-trigger cubic

Comment thread apps/sim/lib/desktop/executor/repository.ts
- Terminal approvals carry their command at args.args.command, so the
  inbox summary read nothing; it now reads the terminal call's real shape,
  and the tests use it.
- A turn budget shorter than the pickup window left a bound call pending; an
  unclaimed call is now settled as never started through the pending-only
  path, as the chat-view wait does.
- Ringing the device is best effort: a publish that throws is logged and can
  no longer break Stop or any other caller.
- Running claimed calls are no longer fetched for the inbox, and offered or
  awaiting calls and cancel items are fetched with separate caps, so neither
  can starve the other.
- No pickup window runs while the user decides: an offer refuses a call still
  awaiting approval, and recording the decision clears any pickup deadline,
  so an allowed call's window starts when it is offered after the answer.
- The per-user rate limit refills every second instead of in one lump a
  minute, so a device that spent its burst can still renew its leases.
- The integration suites delete the audit rows they create.
@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 43 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.

Turn on auto-fix | Re-trigger cubic

This branch was successfully deployed

1 active deployment
Preview — 0b051f58 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