Tell a Bot a person has the wheel, not that its refs are stale - #164
Merged
davidmckayv merged 3 commits intoAug 22, 2026
Merged
Conversation
The computer refuses an action with `409 { error, humanHasControl: true }`
when somebody has taken the browser, and the surface already branches on that
flag: with it the model is told a person has control, without it it is told
`staleRefs`, which its own tool description turns into "the page changed, call
this again and use the new refs". The flag did not survive the server. Every
409 became a `StaleSnapshotError`, which carries only a message, so the branch
in the surface was unreachable and a Bot refused by a takeover was sent back
round the same action against the person who had just taken the browser.
A takeover is now its own condition through the transport and the route. The
status is unchanged, 409 either way, because nothing is broken in either case;
what differs is what the caller should do next, and that is the only thing the
distinction is for. An ordinary stale-ref 409 still maps as it did.
Told apart by the flag rather than by the message, because the message is the
computer's prose and matching on it would break the moment the wording changed.
beardthelion
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
August 22, 2026 13:48
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.
Closes #163.
The computer refuses an action with
409 { error, humanHasControl: true }when somebody has taken the browser, and the surface already branches on that flag: with it the model is told a person has control, without it it getsstaleRefs, which the snapshot tool's own description turns into "the page changed, call this again and use the new refs". The flag did not survive the server, so the branch was unreachable and a refused Bot was sent back round the same action against the person who had just taken the browser.What it does
A takeover is its own condition through the transport, told apart by the flag rather than by the message, because the message is the computer's prose and matching on it breaks the moment the wording changes. Only the computer's own
truecounts: a string, afalse, an absent flag or an empty body stays the ordinary stale-ref condition, so a Bot with genuinely stale refs is never parked waiting for a person who is not coming.The status is unchanged, 409 either way, because nothing is broken in either case. What differs is what the caller should do next, and that is the only thing the distinction is for.
The flag is rendered in one place rather than at the route that happened to need it. The first version of this carried it inside the shared acting helper, which fixed the acting path and left navigate answering as before: navigate renders its own error, and it is the second place the computer flags a takeover. Nine call sites render an error here, so the rendering moved into
errorBodyand a route can no longer answer without carrying what the caller needs from it.Verification
control-refusal-end-to-end.test.tsdrives the real router through the real gateway and transport against a listener answering exactly what the computer answers, over a socket, because the property is that a field survives being serialised, thrown as an error, and re-serialised. Four cases: the flag reaches the caller on the acting path and on navigate, a stale 409 gains no flag, and the refusal is still audited as a refusal.Five in
computer-client.test.tsfor the transport, including the four non-flag shapes and a 409 with no body at all.Three in
app/tests/computer-tool-refusals.test.tsfor the end of the wire, where the distinction is actually drawn for the model: a takeover reportshumanHasControland neverstaleRefs, a stale snapshot still reportsstaleRefs, and a policy refusal is neither.callComputeris exported for it, since standing up the tool registrations and the runtime around them to reach three lines of mapping would test less and cost more.Each guard was checked by removing it: dropping the flag from the renderer turns two red, loosening the transport's equality check turns one red, and making the surface ignore the flag turns one red.
Suite is 886 passing against 877 on
mainwith a database, no failures either side. Typecheck clean across app, server and worker; biome clean.What this does not close
The computer's other control refusal,
TAKE_CONTROL_FIRSTfor a person driving before they have taken the wheel, carries no flag. That is the person's own UI rather than the model's path, and its client discards the error regardless (app/src/lib/computers/control.ts:91), so it wants a different answer than this one.statusFor's branch for a control refusal arriving as aComputerUnavailableErrorwith/control/iin the message is left alone. It looks unreachable for the shipped computer now that both control refusals are 409, but proving that belongs with whatever change removes it.