Repository navigation
feat(agent): size-owner policy, and close three gaps against the daemon - #182
Merged
Merged
Conversation
Two clients on one session used to be a corruption: the second silently
replaced the first, and while both were briefly live they split the pty's
output between them. A session now carries every attached client, and the
design doc's size-owner policy decides whose window the pty follows.
The rules, from docs/designs/remote-workrooms.md:
* Exactly one client owns the size; the pty follows it. Others' resizes are
RECORDED but not applied, so a background window cannot move the pty out
from under whoever is typing in another one.
* Ownership is claimed by ACTING — typing, or clicking — and the recorded
size takes effect at once.
* Only a client advertising a nonzero size may own. A relay forked into a
pipe rather than a terminal reports 0x0.
* The owner leaving makes the size unowned, and the next client to act takes
it — EXCEPT that with exactly one client left it takes the size
immediately, so closing one laptop never strands the terminal at the
geometry of a window nobody is looking at.
**The load-bearing exclusion gets its own module.** A terminal writes to the
same channel the user does: focus reports, cursor-position reports and
device-attribute answers all arrive as "input" with nobody at the keyboard, and
a pane merely being focused would otherwise steal the session. `input.rs` is a
parser, not a substring check, and it carries state between calls — a report
split across two frames must not read as a keystroke because its first half
looked like nothing. Wheel scroll is excluded too; a click is not. The doc
names this as the same failure family as the replay buffer's query stripping.
**Also fixes a bug from the previous PR that the tests could not see.**
`Pty::write_all` was added, unit-tested, and then never called: my edit to the
Input arm was overwritten by a later rewrite of the same file, so the partial
write it exists to prevent was still live. Input now routes through the session
(which is where ownership is arbitrated anyway), and that path uses `write_all`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdxRcKCgPJQxxnTe4aaKK2
…d with Three gaps found by working through what SessionDaemonEndToEndTests covers that the agent does not. All three are behaviours the Swift daemon has and the replacement was about to ship without. **A setsid'd grandchild survived the kill.** `terminate` signalled the pty's child alone. Hanging up a pty reaches its foreground process group, but a child that calls `setsid()` has left both that group and the shell's session, so nothing reached it — quitting the app left it running forever. This is a review finding that was fixed in the daemon (`descendantProcessIDs`) and losing it in the port is a regression, so the walk is ported: parent-pid links, the one relation `setsid` cannot escape, via `proc_listallpids` + `PROC_PIDTBSDINFO` on macOS and `/proc/<pid>/stat` on Linux. The snapshot is taken BEFORE anything is signalled, for the reason the daemon's own comment gives: once the shell is hung up its children start exiting, and a descendant already gone from the table is one no later sweep can find. `kill`/`kill_all` now do this with the store lock released, and `kill_all` sweeps once for every session rather than looping — a scan and up to half a second of waiting per session, under the lock, would have stalled every `list` and `attach` on quit. **`Exited` carried the raw waitpid status, not an exit code.** `exit 7` arrived as 1792. Both ends of this wire already agree on the shell convention — `SessionDaemon.exitCode` produces it, `SessionAttachClient` returns it as its own status — so the same frame meant a different number depending on which backend served the session. `wr-agent attach` also discarded it and always exited 0; it now propagates it, so a command run through an attach is indistinguishable from running it directly. **An oversized session list would have panicked.** `Frame::encode` asserts rather than truncating, so a `Sessions` reply past the 1 MiB cap killed the connection thread and left the client waiting for a frame that never came. It replies `Failure` now, which the app already renders. Tests: `kill_reaches_a_setsid_grandchild` uses a real perl `setsid()` grandchild and observes its pid from outside the session — verified to fail (surviving the full 5s wait) with the descendant walk disabled. Plus exit-code conversion and the list-cap refusal. 111 lib + 11 + 8 integration tests pass; clippy clean with -D warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UdxRcKCgPJQxxnTe4aaKK2
…d run **`AgentControlPlaneTests`** drives `AgentControlClient` against a real `wr-agent serve`. The agent has its own integration tests and they are cheaper, so this covers only what those structurally cannot: the boundary between two languages. The greeting, the envelope and the descriptor list are each encoded in Rust and decoded BY HAND in Swift, and a disagreement about a field width or a byte order would pass every test on either side alone while presenting to a user as a permanently empty sidebar. Verified by reversing the id bytes in `encode_descriptor_list` — the Swift suite goes red, which is the only thing that makes it worth running. Sessions are created the way the app creates them, by running `wr-agent attach` with the configuration in its environment. The app never sends an `Attach` frame — it hands libghostty an attach command to run as the pane's shell — so attach, resize, input and exit stay the Rust suite's to cover, and this does not duplicate them. This does NOT replace `SessionDaemonEndToEndTests`. The daemon still ships during the drain and shares `WorkroomSessionProtocol` with the agent, so its coverage is live coverage; both pairs go together in the deletion PR. **A failing integration test leaked its agent, forever.** Each test ends with `let _ = agent.kill()`, which an unwinding panic skips straight past, and a leaked agent never reaches its idle timeout because it is still holding the session's shell. Development runs had left 88 agents and their shells alive on this machine. `Spawned` is a `Drop` wrapper, so the kill happens during the unwind; a full run now leaks none. 2595 app tests pass; 111 lib + 11 + 8 Rust; clippy and swift-format clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UdxRcKCgPJQxxnTe4aaKK2
|
**The exit-code conversion was in dead code — again.** Greptile caught that `exit_code()` was applied inside `pump_output`, which has had no callers since the reader moved into the session in f7cb1e5; the live path is `read_session`, which kept sending the raw `waitpid` status. So `exit 7` still arrived as 1792, and `wr-agent attach` clamped that to 255. This is the second time in this phase an edit landed in a function nothing calls — `Pty::write_all` was the first. The unit test did not catch either, because both test the helper directly and never reach it through the path that runs. So the fix comes with `the_shells_exit_code_becomes_the_clients`, which asserts the number a real `wr-agent attach` process exits with: 7, verified to report 255 with the conversion reverted. And `pump_output` is deleted rather than left to fool a third reader. **A recycled pid could be sent SIGKILL.** Descendants were recorded as bare pids, and the SIGKILL sweep happens up to half a second after the hangup — long enough for one to exit and its number to be reused. `kill(pid, 0)` cannot tell the difference. They now carry a start time (`proc_bsdinfo.pbi_start_tvsec` on macOS, `/proc/<pid>/stat` field 22 on Linux), and `Descendant::is_running` compares it, so a replacement holding the same pid is not signalled. An unknown start time still falls back to bare existence: failing to signal a real descendant leaves a process running forever, which is the worse outcome. **The input classifier's parameter buffer was unbounded.** A client that sends `ESC [` and then never a final byte made `params` grow across frames without limit, in an agent shared by every session. Capped at 64 bytes — wider than any real sequence — and the sequence is ABANDONED past the cap rather than truncated, so padding cannot get a chosen final byte classified as a report. Declined: a bare Escape not claiming the size. It is real but it is the safe side of a deliberate trade. A lone `ESC` is indistinguishable from the first byte of a report whose tail is in the next frame, so treating end-of-frame as a complete Escape would reintroduce exactly the false positive this module exists to prevent. The next byte resolves it, so the cost is one byte of delay; the only unresolved case is pressing Escape and then nothing at all. 113 lib + 12 + 8 tests on macOS, 125 with terminal-state, 114 + 12 + 8 on Linux; clippy clean with -D warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UdxRcKCgPJQxxnTe4aaKK2
This was referenced Sep 13, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Continues #154 Phase 1, after #181.
Multiple clients on one session, and who owns the size
The design doc's end state is several clients attached to one session at once — a pane on the Mac and a pane elsewhere on the same shell. A second attach now joins rather than replaces, and both see the same output.
That settles an open thread from #181 differently from how it was proposed: there is no superseded client to notify, because nothing is superseded.
What arbitrates instead is a single size owner. The session follows one client's window; other clients' resizes are recorded but not applied until one of them acts, which claims ownership. Only an attached client advertising a nonzero size may own — a relay forked into a pipe reports 0x0, and letting that own would resize every other viewer to nothing. When the owner detaches the size is unowned; with exactly one client left the pty snaps to its size immediately.
The load-bearing part is deciding what "acts" means, because a terminal answers on the same channel the user types on.
vcs/crates/wr-agent/src/input.rsis a parser, not a substring check, and it carries state between calls so a report split across two frames cannot be misread as a keystroke. Excluded from claiming: focus reports, CPR, DSR, DA1/2/3, XTVERSION/XTGETTCAP, OSC colour and clipboard answers, kitty keyboard flags reports (CSI ? n u— one character from a kitty key event), and wheel scroll. A keystroke or a click claims. Same failure family as the replay buffer's query stripping, which the design doc says to recognise before rediscovering it as a bug.Three gaps against the Swift daemon
Found by working through what
SessionDaemonEndToEndTestscovers that the agent does not. All three are behaviours the daemon has and the replacement was about to ship without.A
setsid'd grandchild survived being killed.terminate()signalled the pty's child alone. Hanging up a pty reaches its foreground process group, but a child that callssetsid()has left both that group and the shell's session — so nothing reached it, and quitting the app left it running forever. This was a review finding fixed in the daemon (SessionPTY.descendantProcessIDs), and losing it in the port is a regression. The walk is ported: parent-pid links, the one relationsetsidcannot escape, viaproc_listallpids+PROC_PIDTBSDINFOon macOS and/proc/<pid>/staton Linux.The snapshot is taken before anything is signalled, for the reason the daemon's own comment gives: once the shell is hung up its children start exiting, and a descendant already gone from the table is one no later sweep can find.
kill/kill_allnow do this with the store lock released, andkill_allsweeps once for every session rather than looping — a scan plus up to half a second of waiting per session, under the lock, would have stalled everylistandattachon quit.Exitedcarried the rawwaitpidstatus, not an exit code.exit 7arrived as 1792. Both ends already agree on the shell convention —SessionDaemon.exitCodeproduces it,SessionAttachClientreturns it as its own status — so the same frame meant a different number depending on which backend served the session.wr-agent attachalso discarded it and always exited 0; it now propagates it, so a command run through an attach is indistinguishable from running it directly.An oversized session list would have panicked.
Frame::encodeasserts rather than truncating, so aSessionsreply past the 1 MiB cap killed the connection thread and left the client waiting for a frame that never came. It repliesFailurenow, which the app already renders.Pty::write_allshipped unusedCorrecting the record on this thread, where I reported the partial-input bug as fixed in
5b1f6166. It was not.write_allwas added and unit-tested, but the edit to its call site was overwritten by a later rewrite ofserve.rsin the same session, so what landed still calledpty.writeand dropped the tail of a large paste. The test passed because it driveswrite_alldirectly and never goes through the Input path.Fixed here for real: input routes through
Session::write_input, which classifies the bytes for the size-owner policy and then callspty.write_all. There is no longer a call site that can be missed.Tests
AgentControlPlaneTestsdrivesAgentControlClientagainst a realwr-agent serve. The agent's own integration tests are cheaper and cover more, so this covers only what they structurally cannot: the greeting, the envelope and the descriptor list are each encoded in Rust and decoded by hand in Swift, and a disagreement about a field width or a byte order would pass every test on either side alone while presenting to a user as a permanently empty sidebar. Verified by reversing the id bytes inencode_descriptor_list— the Swift suite goes red.Sessions are created the way the app creates them, by running
wr-agent attachwith the configuration in its environment. The app never sends anAttachframe — it hands libghostty an attach command to run as the pane's shell — so attach, resize, input and exit stay the Rust suite's to cover.SessionDaemonEndToEndTestsis deliberately kept. The daemon still ships during the drain and sharesWorkroomSessionProtocolwith the agent, so that is live coverage; both pairs go together in the deletion PR.kill_reaches_a_setsid_grandchilduses a real perlsetsid()grandchild and observes its pid from outside the session — verified to fail, surviving the full 5s wait, with the descendant walk disabled.A failing integration test leaked its agent, forever. Each test ends with
let _ = agent.kill(), which an unwinding panic skips straight past, and a leaked agent never reaches its idle timeout because it is still holding the session's shell. Development runs had left 88 agents and their shells alive on my machine.Spawnedis aDropwrapper, so the kill happens during the unwind; a full run now leaks none.Verification
.xcresult, not the log tail)cargo clippy --all-targets -- -D warningsclean;cargo fmtandswift-format --strictclean🤖 Generated with Claude Code
https://claude.ai/code/session_01UdxRcKCgPJQxxnTe4aaKK2