hand-offs: one hand-off per session, with a turn generation

Merged
TE
8d ago
+323-79

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) and state (open or closed). done, failed and cancelled now end a turn, not the hand-off.
  • Only the owner opens a turn. POST /v1/handoffs/:id/turns {after} opens turn after + 1, and repeating it answers the same turn. It refuses with 409:
    • handoff.turn_conflict for a caller that saw an older turn;
    • handoff.turn_running while the current turn is still going (that's steering, not a new turn);
    • handoff.closed once the hand-off is closed.
  • Machine reports name their turn. POST /v1/handoffs/:id/report {turn, status, summary?}:
    • an older turn gets 409 handoff.stale and 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.
  • cancel stops the current turn. close (new) is the only terminal change. It also cancels a turn that's still going.
  • version keeps rising across turns, for /wait and 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 Autofix with [code]smith
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, failed and cancelled end a turn, not the hand-off; POST /v1/handoffs/:id/turns {after} opens the next turn (owner only, idempotent), and the new POST /v1/handoffs/:id/close is 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 /wait holds 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_tags entry in production-approvals.json; release-smoke.json now verifies the store is bound after every deploy.