Skip to content

Make native Take control transfer ownership - #1240

Merged
h0x91b merged 2 commits into
mainfrom
fix/dev3-native-take-control-transfer
Aug 4, 2026
Merged

Make native Take control transfer ownership#1240
h0x91b merged 2 commits into
mainfrom
fix/dev3-native-take-control-transfer

Conversation

@h0x91b

@h0x91b h0x91b commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Hi — Claude here, the AI assistant that wrote this branch. Description below is mine.

Summary

An explicit Take control in a native terminal could never transfer the writer lease when another dev3 window or app process held it — it reached the host, came back refused, and left the viewer permanently read-only.

One branch caused it, not a race. The host grants a single writer lease to the first client across every app process (several dev3 processes share one ~/.dev3.0), and WriterOwnership.request(client, "claim") returns writer-active whenever the slot is taken. No action existed that could move a live lease, so the button was architecturally incapable of working. Reproduced deterministically in ~/.dev3.0/logs/2026/08/2026-08-03.log lines 30978–31138: pid 74178 attaches to host 73645 as an observer, its attach-time claim is refused with another client is already the writer, then fifteen Take control refused lines follow while pid 80386 holds the lease.

What changed

A third ownership action, takeover, alongside claim/release:

  • writer-ownership.tstakeover swaps the single writer pointer in one synchronous turn on the host's event loop and returns the displaced client. claim keeps its exact non-stealing semantics, so ordinary attachment, ensureWriter and agent-prompt-native are unchanged.
  • host.ts — sends the displaced client the unsolicited id-0 observer frame (the channel decision 191 built for the vacancy notice) before confirming the winner, so no turn exists in which two clients believe they own the PTY.
  • pty-server.ts — only the explicit gesture uses takeover.
  • Old hosts fail closed. The action is additive, so a host staged before it drops the frame unparsed and the request times out. Only OwnershipTimeoutError with action === "takeover" falls back to a plain claim (which still wins a vacant slot there); a disconnect, auth failure or host error frame is surfaced as itself, never relabelled. A claim refused on such a host becomes an actionable host-too-old state on the read-only strip.

Two further bugs surfaced while verifying, fixed here:

  • Displaced viewers rendered at the wrong grid. A cross-process resize happens in the other process, so the displaced process's appliedCols/Rows was stale and it kept telling its own viewers the old size — the writer's bytes would wrap at the wrong width. The host now broadcasts the canonical grid to the other clients on each writer resize, and nativePtyGeometry prefers the host's number over the local one. Geometry is owner-driven; attach and focus still never resize.
  • Promotion published a PTY one row short. Winning the lease removes the strip above the canvas, so re-fitting inside the role frame's turn measured the pre-removal box and the ResizeObserver then corrected it — two resizes, two repaints. TerminalView now re-fits once, after layout settles.

The strip truncates, so the short label stays in the bar and the why-and-what-to-do sentence rides the Take control button's tooltip (focusable, unlike a span). The button stays clickable in host-too-old on purpose — the slot can still free up when the other window leaves, and disabling it would strand the one recovery path that works. Copy in en/ru/es. Rationale, rejected alternatives and risks: decisions/198-explicit-writer-takeover-across-processes.md.

Evidence this fix is real

src/bun/__tests__/native-takeover-peer.ts is a second real OS process that reattaches via reattachNativeTaskSession, stands up its own pty-server and renderer socket, and sends the identical claimMessage() frame TerminalView.tsx sends — never WriterOwnership or the host protocol directly, and no tmux path. Against unmodified HEAD it fails on seven targeted checks (lease not transferred, refusal reported, input never reached the shell, displaced process never told, host role not followed, exactly-once count 0, geometry still 100x30) and passes with this change. The guardrails stay green on HEAD as well, so they are load-bearing rather than tautological: attaching from a second instance still observes, the same host/shell are reused, pids are unchanged, the tmux sentinel survives.

Known gaps

  • No browser QA. Driving two real app instances meant two full dev3 processes against the shared ~/.dev3.0, which exposed every real task — it surfaced another agent's completion-approval dialog inside the QA browser, one click from completing someone else's task. Aborted deliberately; nothing leaked. The three strip states and the button-to-frame path are covered in jsdom and the cross-process behaviour by the two-process e2e, but no human has yet watched it in a real window.
  • An observer narrower than the canonical grid still clips rather than letterboxing. The dotted-filler renderer is deliberately out of scope.

@h0x91b
h0x91b force-pushed the fix/dev3-native-take-control-transfer branch from 3902651 to 914d101 Compare August 3, 2026 12:07
The host granted its single writer lease to the first client across every dev3
app process, and `claim` only ever took a vacant slot, so a viewer in any other
instance was permanently read-only: Take control reached the host and came back
refused.

Add a third ownership action, `takeover`, which swaps the writer pointer in one
synchronous turn and returns the displaced client so the host can tell it
authoritatively that it is now an observer, before confirming the winner.
`claim` keeps its non-stealing semantics, so attaching, ensureWriter and
agent-prompt-native are unchanged.

Old hosts drop the additive frame unparsed, so only a takeover-request timeout
falls back to a plain claim; every other failure is surfaced as itself, and a
refusal there becomes an actionable host-too-old state on the read-only strip.

Canonical geometry follows the new owner: the host broadcasts the writer's grid
to the other clients on resize, and a promoted viewer re-fits after layout has
settled rather than measuring past the strip it just removed.

Covered by a red-first regression that drives the production Take control frame
from a second real OS process against one real native host.
@h0x91b
h0x91b force-pushed the fix/dev3-native-take-control-transfer branch from 914d101 to 5b1cf34 Compare August 4, 2026 02:03
@h0x91b
h0x91b merged commit dc5c6ad into main Aug 4, 2026
20 of 21 checks passed
@h0x91b
h0x91b deleted the fix/dev3-native-take-control-transfer branch August 4, 2026 02:26
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