Skip to content

feat: end early when the human is gone, and confirm the ending - #10

Merged
Sy-D merged 5 commits into
mainfrom
peer-presence
Sep 3, 2026
Merged

feat: end early when the human is gone, and confirm the ending#10
Sy-D merged 5 commits into
mainfrom
peer-presence

Conversation

@Sy-D

@Sy-D Sy-D commented Sep 2, 2026

Copy link
Copy Markdown
Owner

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 immediately
after 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 has
been 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 always
meant: nobody answered. HandoffOutcome grew once already in 0.4.0, and every
growth is a switch in somebody else's code that quietly stops being
exhaustive — for a difference that changes nothing about what the caller does
next. What changed is why, and why belongs in the wide event:
humanSeen, endedEarly and humanLeftMs.

The ending lost a race with the kill

raiseHand sent ended and then destroyed the sandbox. The relay keeps that
message 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
storageState capture to hold the door open, the message was written to a
socket 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 the
ending, and sendFinal waits 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

Guarantee Test
The relay reports the human on connect, replace and close 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"
A connecting agent is told the current state, not the next change relay.test.ts — "an agent that connects while the human is there is told so at once" (including after a reconnect)
Presence never reaches the phone relay.test.ts — "presence is for the agent only and is never sent to the phone"; socket.test.ts — "…and only presence"
The ending is acknowledged only once it is stored 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 reconnect
sendFinal waits for the receipt, and gives up after 2 s socket.test.ts — "sendFinal waits for the relay to acknowledge the ending", "a relay that never acknowledges the ending costs two seconds, not the handoff"
Present → gone for the whole grace ends the handoff as timeout, endedEarly: true handoff.test.ts — "a human who was there and then closed the tab ends the handoff early" (real relay process, fake page)
A reconnect inside the grace changes nothing handoff.test.ts — "a phone that comes back inside the grace keeps the handoff alive"
never_seen waits the full timeoutMs handoff.test.ts — "a handoff nobody ever opened waits the whole timeout"
A grace longer than the remaining wait is an ordinary timeout handoff.test.ts — "a grace longer than what is left of the wait changes nothing"
Approval mode has presence too handoff.test.ts — "an approval whose human walks away ends early too"
An unusable humanGoneGraceMs is refused before a sandbox exists handoff.test.ts — "a grace that is not a usable number…" (invalid_option, and errors.test.ts requires every code to name its test)
Live: a human who walks away ends the handoff on the absence, not the wait e2e/handoff.e2e.ts — the presence case
Live: a second holder of the link who never answered is told how it ended e2e/handoff.e2e.ts — the channel-answered approval, with a viewer watching
Live: the answering phone is told over the wire (was an observation) e2e/handoff.e2e.ts — both approval rounds

Measured, live (one full bun run test:e2e, Germany → us-west)

  • The abandoned handoff ended after 7 242 ms of a 90 000 ms wait. The human
    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.
  • Every ending was acknowledged before the kill: 204 ms, 192 ms, 293 ms and
    205 ms across the four handoffs that reach it. acked: true in all four.
  • A viewer holding the link when the channel answered was told 306 ms later.
  • A viewer who starts opening the link after the answer usually finds a
    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.
  • 73 live assertions, 82.9 s end to end, zero leaked sandboxes.

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 the
real-Chromium ui.spec), bun run build, dist-smoke (12 exports), and the
full live e2e. The complexity ratchet is untouched: global 28, server.js 59,
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

Sy-D added 4 commits September 3, 2026 03:32
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.
@Sy-D
Sy-D merged commit 96a26db into main Sep 3, 2026
2 checks passed
@Sy-D
Sy-D deleted the peer-presence branch September 3, 2026 02:15
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