backend: hand-off store for cloud work mode (HandoffDO, owner index, /v1/handoffs)
Why
Leo, on cloud work mode: "we want that to be over there. And then we also want to emulate that ChatGPT work mode pretty hard." Comparing ChatGPT Work with Codex: "apparently you can view literally the exact same thing in work and in codex. it's just... you see more stuf fin codex". On waiting for a production migration to store Runs: "why do we need a database here at all?"
So work mode is our existing agent chat (an acpmux session and its transcript) running on a cloud machine. The transcript already belongs to the session. The cloud side only needs to know whose session went where, how it is doing, and how it ended. This PR adds that store as Durable Objects, with no Postgres and no migration.
What
backend/apps/api/src/handoffs/:
-
HandoffDO, one per handed-off session. It holds:- the owner, the agent session id and the cloud machine;
- the size the machine was given, plus auto-sizing's pick and reason as plain text (for example "4 vCPU · 16 GB" / "picked from 3 earlier runs");
- status:
starting,running,needs-you,done,failedorcancelled, withstatusAt,startedAtandfinishedAt; - a short final summary.
It keeps no attempts, no event log and no copy of the transcript. Its table is created only by a create, so a read of an unknown id writes nothing.
-
HandoffIndexDO, one per owner (theAccountIndexDOpattern). It lists the owner's hand-offs, newest first, with status and summary. Each hand-off writes its own row after every change; rows are ordered by version, and if a write is lost the hand-off's alarm retries it. -
Routes (
routes.ts). Errors come back as{error: {code, message}}.Owner (a signed-in session or one of the person's own installs, through
callerOflike the Home routes):Route Request Answer POST /v1/handoffsIdempotency-Keyheader; body{sessionId, machine, size?: {vcpus?, memoryGb?, pick?, reason?}}201 {handoff, machineToken}. The same request again returns the same hand-off. A different request under the same key gets 409handoff.conflict.GET /v1/handoffs?limit=&cursor={handoffs: [{id, sessionId, status, summary, createdAt, updatedAt}], nextCursor}GET /v1/handoffs/:id{handoff}GET /v1/handoffs/:id/wait?after=<version>&timeoutMs={handoff}once its version is pastafter, or at the deadline (at most 25 s). This is the long-poll a notification waits on.POST /v1/handoffs/:id/cancel{handoff}with statuscancelledMachine (the
x-cmux-handoff-tokenheader carrying themachineToken, or the bearer token of a VM install that is bound to the hand-off's machine and belongs to its owner):Route Request Answer POST /v1/handoffs/:id/report{status: running | needs-you | done | failed, summary?}{handoff}. Once the hand-off is done, failed or cancelled: 409handoff.finishedwith the hand-off, which is how a cancelled machine finds out.handoffis{id, sessionId, machine, size: {vcpus, memoryGb, pick, reason}, status, summary, version, createdAt, updatedAt, statusAt, startedAt, finishedAt}. The machine token is returned only by create and is never served on a read. -
Bindings.
HANDOFF_DOandHANDOFF_INDEX_DO, with DO migrationv18, for local/test, development and staging. Production is not touched: no binding and no migration tag. The routes answer 404 there, and the production promote gate (G3) needs a revieweddo_tagsapproval beforev18can reach it.
Staging
Landing on feat-cmux-next deploys cmux-api-staging on its own (backend.yml deploy-staging). There are no Postgres or MySQL migrations, so that job's schema gates pass as they are, and the post-deploy smoke runs /v1/health. There is nothing to dispatch.
Tests
test/handoffs.test.ts covers:
- create, replay and conflict; input validation;
- machine reports through to done with a summary, then the refusal after it finishes;
- strangers, wrong tokens and a person's sign-in get nothing, and an unknown id writes nothing;
- wait (at once past its version, and woken by the next change); cancel, and the machine learning of it;
- list paging; the alarm retrying a lost index write;
- a VM install bound to the machine may report, and one bound to another machine may not.
Run remotely (cmux-ci, 32 vCPU Linux), all passing:
bun run typecheckandbun run lint:size;vitest run test/handoffs.test.ts test/unbound-objects.test.ts test/cloud-vm-install.test.ts(15 tests);wrangler deploy --dry-runfor staging and production.
Risk
Low. These are new routes and two new DO classes with additive bindings on non-production environments, and existing routes are unchanged.
The one shared piece is the DO migration tag. #25682 also adds a v18 (TrajectoryDO). Whichever lands second must renumber to v19, and git will flag it as a conflict on the same lines.
Not in this PR
- The app side: the composer's Cloud target creating a hand-off, and a watcher that turns
waitinto a notification. - The machine side reporting status.
- Auto-sizing's pick: this PR only stores it.
The earlier larger idea (a Run DO that copied next's Postgres Run model and kept R2 artifacts) was dropped before any PR. The transcript is already the session's.
🤖 Generated with Claude Code
Agent session: agent session