feat: end early when the human is gone, and confirm the ending - #10
Merged
Conversation
Two things the relay knew and never said. The first is whether anybody is on the other side. The relay answers the heartbeats itself, so a pong proved the relay was alive and said nothing about the person: a human who opened the handoff and closed the tab cost the agent the whole timeoutMs — five minutes by default, against a browser session with a ten-minute life. It now reports the phone's socket to the agent on every connect, replace and close, and once right after an agent connects. The core runs never_seen -> present -> gone, and ends the handoff once somebody who was there has been gone for humanGoneGraceMs (default 60 s, the proxy's own cut plus its reconnect). A link nobody ever opened is untouched and waits out timeoutMs. The outcome is still `timeout`. Nobody answered, which is what that value has always meant, and a seventh member would break exhaustive switches in callers for a difference they do not act on. The wide event carries the difference: humanSeen, endedEarly and humanLeftMs. The second is that the ending was stored. `ended` was written to a socket whose sandbox was already being deleted, so whether anyone else holding the link ever saw it was a race — logged as an observation in every live approval round. The relay now answers `ended_ack` once it has stored the ending, and sendFinal waits up to 2 s for it before the sandbox is killed. Measured live: a handoff whose human walked away ended after 7.2 s of a 90 s wait, and every ending was acknowledged in 192-293 ms.
Review round 1 (Opus, adversarial, probed against the real relay). H1, the one that cost something: `stop()` cleared the pending grace and left `saw()` live, and socket.ts delivered `presence` after `close()`. The phone closes its socket the instant it renders the ending, so the last report of almost every handoff is a departure — which armed a fresh 60 s timer nobody would ever clear (the reviewer's probe idled 58 082 ms after `raiseHand` had resolved) and recorded a `humanLeftMs` of 3 105 ms on a 3 s handoff whose human never left. The watch now refuses everything after `stop()`, `runHandoff` stops feeding it at `over`, and the socket drops presence once it is shutting down. Pinned three ways: the state after `stop()`, a child process that must exit, and a handoff whose human hands back and closes at once carrying no `humanLeftMs`. M2: `sendFinal` waited two seconds for a receipt for a message `deliverFinal` knew it had never written — 4 s on the `disconnected` path, where the caller is already having a bad time. `deliverFinal` now reports whether it sent, and the test drives a relay that is gone for good. M1: the `humanLeftMs` doc claimed it was absent when the human was connected at the end; a flap-then-handback carries one. Documented as what it is. M4: the live grace check had a ceiling and no floor, so a build with no grace at all would have passed it. Measured 5 956 ms of a 5 000 ms grace. M3: the CHANGELOG promised an assertion on the viewer who arrives after the answer — the one thing ADR 0009 explains it deliberately does not do. It now says what is asserted and what is measured, with the number from the run. L1: one line in the option's docs for the human who locks their phone.
Review round 2 (GPT-5.6 Sol, adversarial, against the real relay). M1, the one that defeats the point of the feature: a human could open the handoff and close it again entirely while the agent's socket was down — a proxy cut, a reconnect backoff — and both announcements would find no agent and be dropped. On reconnect the relay said only "nobody is here", which is also what a link nobody ever opened says, so the handoff waited out the whole `timeoutMs` and the event claimed nobody had come. The relay now keeps the two facts that survive an outage — has a human ever been here, and how long the current state has held — and sends them with every presence report as `seen` and `sinceMs`, both optional so an older relay still speaks the protocol. The core leaves `never_seen` on `seen`, and runs the grace and `humanLeftMs` from when the human actually left rather than from when it heard. The report goes out before the buffered answer, because an answer settles the handoff and a settled handoff refuses everything it learns afterwards; a backdated grace is armed with a 250 ms floor so it cannot beat that answer to the settle. M3: a phone that came back could still drive the page after the grace had ended the handoff. `onHuman` guarded on "a terminal message arrived", and a grace timeout is not one, so a tap could land during the seconds teardown spends waiting on a QR scan or the ending's receipt. Nothing from the human is acted on once `over`, and the focus probe is guarded on both sides. Minor 1: a `humanGoneGraceMs` above 2 147 483 647 is refused — Node truncates that timer to 1 ms, so a three-week grace would have ended the handoff at once. Minor 3: `HandoffResult.durationMs` is the settlement duration the wide event carries. It used to include the ack, the CDP detach and the sandbox teardown, which is nobody's waiting time. Minor 4: the ack is latched on arrival. A relay fast enough to answer before the local send callback resumed put its receipt in a gap where it was dropped, and teardown then spent two seconds not-waiting for an ending the relay had. Nits: the README error table lists `invalid_option` with its range, and the local ack test asserts `acked: true` rather than the presence of a log line. Sol's Major 2, Minor 2 and Nit 2 were already fixed in round 1.
Review round 3 (second Opus adversarial pass). MAJOR 2: the two-second ack budget exists because the connection reconnects inside it — and nothing re-sent the ending when it did. A socket that died between the local write and the relay's store left `lastEnded` unset: the phone stayed on "Reconnecting…" and the next viewer of the link was told nothing, which is the exact failure this release was written to close. The ending is now held while `sendFinal` waits and re-sent on every reconnect in that window, and the relay stores the same ending twice without complaint. Pinned by a relay that dies under the first ending and answers on the next connection. MAJOR 3: the accepted minimum grace was the value that fails. Measured against the real relay, `humanGoneGraceMs: 1000` ended a healthy handoff at 4 s of a 20 s wait on the first 60 s proxy cut, because the phone's reconnect takes about a second; 1 200 survived. The floor is now 5 000 — five times the reconnect it has to clear — and the option doc, the README row, the error message and ADR 0009 say it is a floor and not a safe value. MINOR 3: both test harnesses classified relay-originated traffic from a hand-written string set. They are keyed by `RelayToAgent` now, the way every other vocabulary in this repo is keyed by its union; a third member fails typecheck in both files, which I checked by adding one. Its MAJOR 1 and MINOR 2 are round 1's fixes, re-verified there.
Verification pass over the three fix rounds (Opus, real relay, every fix
reproduced rather than taken on the tests' word).
F1: round 3 raised the grace floor to 5 000 in six places and missed two. One of
them is `src/errors.ts`, which ships — `dist/index.d.ts` told a TypeScript
consumer that 1 000 ms is accepted while the runtime refused 4 999. The other is
the CHANGELOG, thirty lines below its own bullet saying 5 000. Both now name the
range, and a grep of the tree finds no third.
F2: ADR 0009 described a bare `{ human }` report and a `sendFinal` that only
waits. It now records what rounds 2 and 3 actually shipped: `seen` and `sinceMs`
and the visit they exist for, why the report goes out before the buffered
answer, the 250 ms floor under a backdated grace — measured, not chosen: the
relay writes both frames 0 ms apart and the floor tolerates 240 — and why the
ending is re-sent rather than written once.
F3: `sinceMs` is clamped into [0, the age of the handoff]. It is a relay-local
delta, so there is no clock skew to correct, but a wall-clock step in the
sandbox would otherwise report an absence older than the handoff — which arms
the 250 ms floor and ends a handoff with a human possibly still on it. The
verifier measured that: 251 ms on a fresh handoff with a 2 s grace.
F4: no code change. The socket comment now says the duplicate ending on the
down-socket path is expected and why it is harmless — the relay's store is an
assignment, it acks each copy, and a second ack outside an active waiter
resolves nothing.
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.
Two things the relay knew and never said.
The human closed the tab
The relay answers the heartbeats itself, so a pong proved the relay was alive
and said nothing about the person on the other end. A human who opened the
handoff, looked at it and closed the tab cost the agent the whole
timeoutMs—five minutes by default, against a Solari browser session that dies about ten
minutes after creation. Half a session's life, spent on somebody who had
already left. It was the first entry under Limitations in the README.
The relay now reports the human's socket to the agent:
{ "type": "presence", "human": boolean }on every connect, replace and close, and once immediatelyafter an agent connects, so a reconnecting agent starts from the truth rather
than from what it believed before the 60 s proxy cut.
The core runs
never_seen → present → gone. Once somebody who was there hasbeen gone for
humanGoneGraceMs(default 60 000 ms, option, floor 1000),the handoff ends. A phone that comes back inside the grace resets it — which is
what makes the default safe, because the preview proxy cuts an idle WebSocket
after exactly 60 s and the phone reconnects about a second later. A link nobody
ever opened is deliberately untouched: an unscanned QR code is the ordinary
wait, and it runs the full
timeoutMs.No new outcome. It ends as
timeout, which is what that value has alwaysmeant: nobody answered.
HandoffOutcomegrew once already in 0.4.0, and everygrowth is a
switchin somebody else's code that quietly stops beingexhaustive — for a difference that changes nothing about what the caller does
next. What changed is why, and why belongs in the wide event:
humanSeen,endedEarlyandhumanLeftMs.The ending lost a race with the kill
raiseHandsentendedand then destroyed the sandbox. The relay keeps thatmessage for whoever opens the link next — the phone's whole terminal overlay is
built on it — but in approval mode, which tears down in milliseconds and has no
storageStatecapture to hold the door open, the message was written to asocket whose process was already being deleted. Every live approval round
logged that as an observation rather than an assertion.
The relay now answers
{ "type": "ended_ack" }once it has stored theending, and
sendFinalwaits up to 2 s for it before anything is killed.Against a relay too old to answer, teardown proceeds exactly as before.
Guarantees, and the test that holds each one
relay.test.ts— "the agent is told when the human arrives and when they leave", "a phone replaced by a second one is a leave and a join, not silence"relay.test.ts— "an agent that connects while the human is there is told so at once" (including after a reconnect)relay.test.ts— "presence is for the agent only and is never sent to the phone";socket.test.ts— "…and only presence"relay.test.ts— "the relay acknowledges the ending once it has stored it" (a late human is then told), and the same after an agent reconnectsendFinalwaits for the receipt, and gives up after 2 ssocket.test.ts— "sendFinal waits for the relay to acknowledge the ending", "a relay that never acknowledges the ending costs two seconds, not the handoff"timeout,endedEarly: truehandoff.test.ts— "a human who was there and then closed the tab ends the handoff early" (real relay process, fake page)handoff.test.ts— "a phone that comes back inside the grace keeps the handoff alive"never_seenwaits the fulltimeoutMshandoff.test.ts— "a handoff nobody ever opened waits the whole timeout"handoff.test.ts— "a grace longer than what is left of the wait changes nothing"handoff.test.ts— "an approval whose human walks away ends early too"humanGoneGraceMsis refused before a sandbox existshandoff.test.ts— "a grace that is not a usable number…" (invalid_option, anderrors.test.tsrequires every code to name its test)e2e/handoff.e2e.ts— the presence casee2e/handoff.e2e.ts— the channel-answered approval, with a viewer watchinge2e/handoff.e2e.ts— both approval roundsMeasured, live (one full
bun run test:e2e, Germany → us-west)left at 1 587 ms, the grace was 5 000 ms, and the ending followed 6 060 ms
after the tab closed — grace plus one teardown.
205 ms across the four handoffs that reach it.
acked: truein all four.404: the sandbox stops serving about a second later, and a fresh HTTPS plus
WebSocket handshake costs about as much again. That one is measured and
logged, not asserted — the ADR says why keeping the sandbox alive to serve it
would be the wrong trade.
Gates
bun run lint(incl. the embed-sync check),bun run typecheck,bun run test(309 pass, 0 fail — unit, the real relay process, and thereal-Chromium
ui.spec),bun run build,dist-smoke(12 exports), and thefull live e2e. The complexity ratchet is untouched: global 28,
server.js59,nothing relaxed — the presence machine is a factory function next to
runHandoff, not another branch inside it.Version is deliberately not bumped. Decisions and rejected alternatives:
ADR 0009.
🤖 Generated with Claude Code