Skip to content

fix(ui-bridge): stop a turn or run by a target that is never reused - #1848

Merged
bbondy merged 5 commits into
mainfrom
fix-cancel-target-reuse
Oct 9, 2026
Merged

bbondy merged 5 commits into
mainfrom
fix-cancel-target-reuse

Conversation

@chrislacy

Copy link
Copy Markdown
Collaborator

Closes #1847

User impact: none for terminal and desktop users, who send one action at a time. A client that connects, drops and sends a cancel late can no longer stop a later turn, and can now stop a manifest run.

The problem

This follows #1783 and its fix in #1784. #1784 let a client say which turn a cancel was for, using the turn number. A turn number repeats: after session.rewind the next turn gets a number that was already used. A cancel meant for the earlier turn 3 therefore stops the new turn 3. A manifest run has no turn number, so a cancel could never name one. Clients that reconnect and send actions late, such as the planned mobile client, hit this.

Reproduce

The affected front end is any client of bravebot-rpc (newline-delimited JSON). The terminal and desktop apps do not send late cancels, so there is no screen to walk through. The reproduction is the client test that drives a real bravebot-rpc against a scripted model.

  1. Build and run the TypeScript client checks, which start a real bravebot-rpc: BRAVEBOT_ALLOW_UNCONFIGURED_BUILD=1 make check-agent-client.
  2. The test "after a rewind a turn is numbered again, and a cancel meant for the earlier turn with that number stops nothing" sends a turn, rewinds, sends again, then cancels with the first turn's identity.

Observed: I did not run this against the parent commit. I reproduced the old keying by making each turn's target equal to its turn number in send_turn. With that change, make check-agent-client failed only that test (233 of 234 passed), and the 83 bravebot-ui-bridge unit tests still passed. The new Rust test a_turn_after_a_rewind_has_a_new_target_though_its_number_repeats also failed with that change, at the assertion that the two turns' targets differ.
Expected: the second turn has the same number and a different target, and a cancel naming the first target answers { "cancelled": false } and stops nothing.

The fix

Each turn and each manifest run gets a target from a counter that never repeats within a bridge process. turn.send and manifest.run return it, and the session view carries the target of the turn on screen. turn.cancel takes target: it stops the turn or run with that target and nothing else, and answers cancelled: true or false. A cancel with no target stops whatever is running, as before. A cancel that still sends the old turn parameter is refused, because reading it as naming nothing would stop whatever is running.

The TypeScript client names the target the caller gives, or the one on screen. It names none while a send is unanswered or after the view has ended, since the target it shows is then stale. A runtime that does not advertise cancel: expected_target reports no targets; the client accepts that and sends unnamed cancels to it. A session with a view cannot start a manifest run, so the view never has to carry a run's target. A target is unique within one bridge process, so a restarted bridge counts again.

This also adds tests for the question-number and trust-reply behaviour from #1784: a session with no question numbers left now fails its test at once, and a repeated trust reply is shown not to wait behind a running turn.

Test plan

  • cargo fmt --all -- --check - passed
  • cargo clippy -p bravebot-ui-bridge --all-targets -- -D warnings - passed
  • cargo test -p bravebot-ui-bridge - 343 passed, 0 failed
  • make check-agent-client - 234 of 234 passed, including the tests that drive a real bravebot-rpc
  • make check-spec - exit 0
  • New Rust rewind test fails when the target is set to the turn number - observed, then restored
  • New test that a session with a view refuses manifest.run fails when that refusal is disabled - observed, then restored
  • The legacy-runtime scenario fails against the client source before the absent-target fix - observed
  • Not run: make check-affected, make check-all; they are slow and fail on unrelated environment problems on this machine
  • Not tested: a late cancel across a bridge restart (see the process-lifetime note above); a cancel from a client with a view while a manifest run is in flight, which cannot occur because the bridge refuses that combination
  • CI passes cleanly
Spec and tests

RPCVIEW-6 (targets, the turn refusal, one-process uniqueness, runs and views) and RPCVIEW-7 (what the client names) in docs/specs/session-view.md are updated, with the tests that pin each. ui/docs/phase-0-rpc-protocol.md documents target on turn.send, manifest.run, the view and turn.cancel.

A session with no question numbers left now fails its test at once instead of waiting for an answer that cannot come. A repeated trust reply is tested to be refused without waiting behind a running turn, which would hold up a cancel on the dispatch thread. The question counter notes why it is a compare-and-swap loop.
A turn number repeats after session.rewind, and a manifest run has none, so a cancel that named a turn could still stop a later turn with the same number, and could never stop a run. Each turn and run now gets a target that is never handed out twice. turn.send and manifest.run return it, the session view carries the target of the turn on screen, and turn.cancel stops the one it names and nothing else. A cancel that still names a turn number is refused rather than read as naming nothing. capabilities.actionTargets advertises cancel as expected_target.

The TypeScript client names the target the caller gives, or the one on screen, and names none while a send is unanswered or once the view has ended, where what it shows is stale. cancel() takes an optional target and reports whether the bridge stopped it. A runtime that reports no targets is still accepted: an absent target reads as the idle target 0 and the client sends unnamed cancels to it. The client no longer tracks the largest turn it sent.

RPCVIEW-6 states the target rule, including that a target is unique within one bridge process and that a session with a view cannot start a run. RPCVIEW-7 states what the client names. The protocol document gives one return shape for turn.cancel and the capability's shape.
Same behavior: an absent target reads as 0, and a target that is not a number is refused.
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

[puLL-Merge] - brave/bravebot@1848

Description

Replaces turn-number cancel targeting (RPCVIEW-6) with a process-unique target id. Turn numbers repeat after session.rewind, and manifest runs had no number of their own, so turn.cancel { turn } could not exactly name what to stop. Each turn and manifest run now gets a monotonic target from a global AtomicU64. turn.send and manifest.run return it, and the session view carries it. turn.cancel takes target and rejects the legacy turn param. The capability string changes from expected_turn to expected_target. On the TS client, cancel(target?) returns CancelResult, and the client drops its sent bookkeeping in favor of the view's target.

Possible Issues

  • Explicit target silently widened on older runtimes. In client.ts cancel(target), when namesTargets is false, named becomes undefined. The request is sent without a target, so it stops whatever is running. A caller passing first.target to cancel a stale turn can kill the current turn. Result is {cancelled: null}, but the damage is done. When the runtime can't name targets, an explicit target should be refused (UnsupportedError) or skipped.
  • a_session_with_a_view_cannot_start_a_run has no implementation in diff. start_run in bridge.rs adds no view check. Either the test fails, or the guard lives outside this diff. Without the guard, the view's target stays at the last turn's id. A view-driven Stop then names the old turn, returns cancelled: false, and leaves the run running.
  • a_repeated_trust_reply_does_not_wait_behind_a_running_turn has no matching code change. trust.reply handling is untouched here, and the protocol doc adds no_such_request for repeated trust replies. Same concern: the behavior is either missing or out of scope for this PR.
  • Version not bumped on a breaking wire change. actionTargets.version stays 1 while cancel semantics change. Old clients degrade correctly only because the string comparison fails. They then lose all three guarantees (questionIds, trust) because readActionTargets is all-or-nothing. Consider version: 2 or per-field detection.
  • Stale shown target. After a turn completes, current.target stays at the finished turn's id. A Stop pressed then returns cancelled: false instead of the legacy "stop whatever" behavior. This only matters if something non-view-visible is running (e.g. a run started without a view), but the semantics are surprising.
  • Weak target validation in send(). Only typeof === 'number' is checked; negative, fractional, and NaN values are accepted. decodeUpdate uses isCount, so the two paths are inconsistent.
  • target: 0 from older runtimes is ambiguous. send() returns target: 0 for an older runtime, which looks like a real id. Callers may pass it to cancel(0). Consider target: number | null.
  • Unrelated changes bundled in.
    • turn.rs adds a comment claiming fetch_update is deprecated for try_update; verify this claim.
    • refusal.rs drops the sender (let (_, answers_rx)), which changes what that test exercises.
Changes

Changes

  • running.rs: Running.{turn, run} replaced by target: u64. Adds next_target(), a global AtomicU64 starting at 1.
  • bridge.rs:
    • turn.send and manifest.run allocate a target and return it.
    • manifest.run no longer reads s.turns.
    • cancel_turn rejects turn, matches on target, and unifies the stop and watch-stop path.
    • cancel_turn returns {cancelled} only when a target was named.
  • emit.rs / view.rs: view_started and View::started take target. Update serializes target. action_targets() now advertises expected_target.
  • targets_tests.rs:
    • Tests renamed to target terminology.
    • New uniqueness test.
    • New test that legacy turn is refused.
    • New trust-reply lock test.
  • tests/manifest.rs: run-target cancel test; "view blocks run" test.
  • tests/rewind.rs: send/finish split; test that the target differs across a rewind.
  • client.ts:
    • namesTurns renamed to namesTargets; sent removed.
    • send returns target, defaulting to 0.
    • cancel(target?) names the explicit target or the on-screen target, and returns CancelResult.
  • interface.ts: SendResult.target, CancelResult, and the new cancel signature.
  • view.ts / wire.ts:
    • ViewState and ViewUpdate gain target.
    • decodeUpdate defaults a missing target to 0.
    • Capability check now expects expected_target.
  • Fixtures/scenarios/tests: target added everywhere; cancel scenarios renamed and extended; new rewind real-RPC test.
  • phase-0-rpc-protocol.md: documents target, the legacy turn refusal, per-process uniqueness, and the repeated trust reply error.
sequenceDiagram
    participant UI as Client (agent-client)
    participant B as Bridge
    participant R as running::next_target
    participant W as Worker

    UI->>B: turn.send {session, prompt}
    B->>R: next_target()
    R-->>B: target N
    B->>B: open.running = Running{target N}
    B-->>UI: session.view.update {turn, target N, running}
    B->>W: spawn turn
    B-->>UI: {turn, target N}

    Note over UI,B: later / after rewind
    UI->>B: turn.cancel {session, target M}
    alt "turn" param present
        B-->>UI: error bad_request
    else M == running.target and not finished
        B->>W: cancel.cancel()
        B->>B: watches.stop_firing()
        B-->>UI: {cancelled: true}
    else M stale
        B-->>UI: {cancelled: false}
    else no target
        B->>W: cancel whatever running
        B-->>UI: {}
    end
    W-->>B: turn.error Cancelled
    B-->>UI: session.view.update {status: cancelled}
Loading

@netzenbot-reviewer netzenbot-reviewer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation: comments only

What this pull request does

This replaces the turn number with a new per-process "target" as the way turn.cancel says which turn or manifest run it means. The bridge hands out each target from a global counter that never repeats. turn.send and manifest.run now return it, and the session view carries the target of the turn on screen. turn.cancel takes target, stops only the turn or run with that target, and answers cancelled: true or false. A cancel with no target still stops whatever is running, and one that still sends the old turn parameter gets bad_request. This closes a hole where, after session.rewind, a late cancel for the old turn 3 could stop the new turn 3, and a manifest run had nothing a cancel could name. The advertised capability changes from cancel: expected_turn to cancel: expected_target. The TypeScript client names the target the caller passes, or else the one on screen, and names none while a send is unanswered, after the view has ended, or against a runtime that does not advertise targets. AgentSession.cancel now returns {cancelled: boolean | null} and SendResult gains target. The diff adds Rust tests (rewind, manifest run, view refusal, trust-reply lock, question exhaustion), updates the scenario fixtures, and updates the specs and protocol docs. The "a session with a view cannot start a manifest run" refusal is pinned by a new test, but this diff shows no new production code for it, so it appears to rely on existing behaviour.

How this review reached its recommendation

The review read 54 changed files.

What it checked:

  • Written best practices. The changes were compared with 20 rules from this project's best-practice documents (dependencies, paths, shared-implementation, specs, tests, ui, writing).
  • Bugs. The changes were read for mistakes that would make the code misbehave or stop it building.
  • This project's own criteria. The changes were checked against these questions:
    • Whether the change agrees with the project's specs, and whether a change to behaviour a spec describes also updates that spec and its tests.
    • Whether untrusted content (text from the web, files or tools) can influence a decision, or be given a better trust label than it came with, anywhere other than the places built for that.
    • Whether the tests would fail if the behaviour broke, rather than covering only the allowed case or passing with the bug put back.
    • Whether the change does what its title says and nothing more, with no unrelated refactor and no new dependency, permission or network destination the title does not explain.

The checks flagged 2 possible problems. 2 of them went to a second reader, who checked each against the full source code rather than only the diff, and kept 2.

Comment thread docs/specs/session-view.md Outdated
Comment thread packages/agent-client/src/common/client.ts Outdated
…e one

cancel(target) dropped the target against a runtime that does not advertise action targets and sent an unnamed cancel, which stops whatever is running. A caller that named a stale turn could stop the current one. It now throws UnsupportedError and sends nothing.
turn.send accepted any number as a target, including a negative or fractional one, where a view update already required a count. Both paths now refuse it, with a wire test and a scenario that pin the refusal. The spec and protocol text for the cancel's `turn` parameter now state the present rule and its reason rather than what an earlier version did.

@netzenbot-reviewer netzenbot-reviewer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation: approve

What this pull request does

Each turn and each manifest run in the ui-bridge now gets a target number from a process-wide counter that never repeats. turn.send and manifest.run return it, the session view carries the target of the turn on screen, and turn.cancel takes target instead of the old turn number. A cancel naming a target stops only the running turn or run with that target and answers { "cancelled": true|false }; a cancel naming nothing still stops whatever is running and answers {}, and a cancel that still sends turn is refused with bad_request. This fixes a delayed cancel for an earlier turn 3 stopping the new turn 3 after session.rewind reuses the number, and lets a cancel name a manifest run, which has no turn number. The capability advertised in actionTargets changes from expected_turn to expected_target, and the TypeScript client's cancel(target?) now returns { cancelled }, names the on-screen target only when no send is unanswered and the view has not ended, and throws UnsupportedError if given a target by a runtime that does not advertise it. The diff matches the description; it also adds tests for the question-number and repeated trust-reply behaviour, and no terminal or desktop screen changes.

Trying it by hand

The description does not say how to try this change. These steps would:

  1. Run the automated reproduction: BRAVEBOT_ALLOW_UNCONFIGURED_BUILD=1 make check-agent-client and expect every test to pass, including 'after a rewind a turn is numbered again, and a cancel meant for the earlier turn with that number stops nothing'. Also run cargo test -p bravebot-ui-bridge.
  2. To try it by hand, start bravebot-rpc (the NDJSON stdio bridge) against a configured or stub model and send agent.info. Expect capabilities.actionTargets.cancel to be expected_target.
  3. Send session.new for a directory, then turn.send with a prompt. Expect the response to contain both turn and target, and a session.view.start view to carry the same target.
  4. While a turn is held, send turn.cancel with target set to a different number. Expect { "cancelled": false } and the turn to keep running. Then send it with the real target and expect { "cancelled": true } and a turn.error of kind cancelled.
  5. Send session.rewind with steps: 1, then turn.send again. Expect the same turn number as before but a different target, and a cancel naming the earlier target to answer { "cancelled": false }.
  6. Send turn.cancel with the old turn parameter. Expect a bad_request error and nothing stopped. Send it with neither parameter and expect {} and the running turn to be cancelled.
  7. Start a manifest run with manifest.run on a session that has no view, and expect a target in the response. A cancel naming it should answer { "cancelled": true }. Check that manifest.run on a session after session.view.start is refused with bad_request.
How this review reached its recommendation

The review read 9 of the 56 changed files: only those that changed since the bot last reviewed this pull request.

What it checked:

  • Written best practices. The changes were compared with 20 rules from this project's best-practice documents (dependencies, paths, shared-implementation, specs, tests, ui, writing).
  • Bugs. The changes were read for mistakes that would make the code misbehave or stop it building.
  • This project's own criteria. The changes were checked against these questions:
    • Whether the change agrees with the project's specs, and whether a change to behaviour a spec describes also updates that spec and its tests.
    • Whether untrusted content (text from the web, files or tools) can influence a decision, or be given a better trust label than it came with, anywhere other than the places built for that.
    • Whether the tests would fail if the behaviour broke, rather than covering only the allowed case or passing with the bug put back.
    • Whether the change does what its title says and nothing more, with no unrelated refactor and no new dependency, permission or network destination the title does not explain.

None of these checks flagged anything, so there was nothing to double-check.

@chrislacy
chrislacy requested a review from bbondy October 9, 2026 04:56
@bbondy
bbondy merged commit f58d5ce into main Oct 9, 2026
24 checks passed
@bbondy
bbondy deleted the fix-cancel-target-reuse branch October 9, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

turn.cancel names turns by a number that repeats after rewind, so a late cancel can stop the wrong turn

3 participants