hand-offs: one hand-off per session, with a turn generation
A cloud chat's hand-off now lasts for the whole session and counts turns. A follow-up in the same cloud chat can report needs-you and done.
Leo, 2026-10-28: "We want to have that for the cloud machines too, and just getting that cloud stuff just going, just simple, easy. Like ChatGPT work." The design (leo-workspace design/cloud-work-mode.md) says the chat is the task, and the person comes back to the same session from anywhere.
Problem. cmux#26578 made done, failed and cancelled final for the whole hand-off. So turn 2 of a continuing cloud chat got 409 handoff.finished, and nothing could tell the app it needed you or had finished (cmuxterm-hq#4706).
The lifecycle, agreed on cmuxterm-hq#4706:
- One hand-off id per session. The hand-off carries
turn(1 at create) andstate(openorclosed).done,failedandcancellednow end a turn, not the hand-off. - Only the owner opens a turn.
POST /v1/handoffs/:id/turns {after}opens turnafter + 1, and repeating it answers the same turn. It refuses with 409:handoff.turn_conflictfor a caller that saw an older turn;handoff.turn_runningwhile the current turn is still going (that's steering, not a new turn);handoff.closedonce the hand-off is closed.
- Machine reports name their turn.
POST /v1/handoffs/:id/report {turn, status, summary?}:- an older turn gets 409
handoff.staleand changes nothing, so a late or retried turn-1 report can't touch turn 2; - a turn not yet opened gets
handoff.turn_out_of_order; - an ended turn gets
handoff.turn_finished, except an exact repeat of its final report, which answers 200 and moves no version, so no watcher wakes to notify twice; - every refusal carries the hand-off, so the machine learns the current turn from it.
- an older turn gets 409
cancelstops the current turn.close(new) is the only terminal change. It also cancels a turn that's still going.versionkeeps rising across turns, for/waitand the owner list. A turn that ended doesn't end a wait; a closed hand-off does. The list shows one row per session, with its turn and state.- Rows from before this change read as turn 1, open (a column added on first read; staging only, since nothing calls the store yet).
Reachable where it should be. Local, development and staging bind both hand-off Durable Objects. Production gets new DO classes only through prod-promote.ts and a reviewed v18 do_tags entry in production-approvals.json, so it keeps answering 404 until then. release-smoke.json now checks this after every deploy: an unsigned GET /v1/handoffs must answer 401 where the store is bound, and 404 on cmux-api.
Tests (backend/apps/api/test/handoffs.test.ts, 11 tests, all passing on cmux-ci Linux; typecheck and the file-size lint pass too):
- turn 1 finishes, turn 2 opens on the same session and reports
needs-you, and the list and wait show turn 2; - a late turn-1 report is stale and leaves turn 2 unchanged;
- the machine can't open a turn or skip one;
- no turn opens while one is running;
- a repeat of the open-turn call answers the same turn;
- a cancel or close racing a same-turn report has one whole winner;
- an exact repeat of the final report wakes no watcher;
- close is terminal and ends a wait at once.
The release-rails suite passes, apart from cmux-old's spec test, which needs a git checkout that the cmux-ci snapshot doesn't have.
Risk: low. Nothing calls the store yet (the machine reporter and composer come next), and production has no binding. The route shapes change compatibly: report now requires turn, and there's no caller to break.
Agent session: agent session
š¤ Generated with Claude Code
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Hand-offs now last the whole session with a turn generation, so a follow-up in the same cloud chat can report needs-you and done instead of being refused as finished.
Turn lifecycle
done,failedandcancelledend a turn, not the hand-off;POST /v1/handoffs/:id/turns {after}opens the next turn (owner only, idempotent), and the newPOST /v1/handoffs/:id/closeis the only terminal change.- A hand-off is now per session: its id derives from owner and session, so a second create for the same session answers the same hand-off and no longer needs an
Idempotency-Key; another machine or size for the session conflicts. - Machine reports name their turn: a late report of an older turn is refused (
handoff.stale) and changes nothing, while an exact repeat of a final report answers 200 without waking watchers. - A report that changes nothing moves no version (no wake, no index write); each mutation's checks and write run in one transaction, so no check goes stale before its write.
- The record version keeps rising across turns, so
/waitholds until the next turn opens; the owner list now shows each hand-off's turn and state.
Migration and rollout
- Rows from before this change read as turn 1, open, via a column added on first read (staging only; nothing calls the store yet).
- Production keeps answering 404 until a reviewed v18
do_tagsentry inproduction-approvals.json;release-smoke.jsonnow verifies the store is bound after every deploy.