fix(web-channel): scope turn cancellation to request_id so a stale cancel cannot kill the next turn (#4760)#4765
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5272c798d1
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…ncel can't kill the next turn (tinyhumansai#4760) `cancel_chat` / `channel_web_cancel` cancelled whatever turn was in `IN_FLIGHT[thread]` with no `request_id` scoping (the RPC + socket cancel API carried only client_id + thread_id). So a client that timed out on request A and then sent request B — which supersedes A on the same thread — had A's late, thread-wide cancel tear down B, killing the newer turn at t=0. `channel_web_cancel` also escalated to the thread-wide `cancel_session` when nothing matched, which would tear down a newer turn too. Make cancellation request-scoped: - Add the pure predicate `cancel_should_target(requested, in_flight)`: a scoped cancel fires only when it targets the request actually in flight; an unscoped cancel (None) still stops whatever is running. - Add `cancel_chat_scoped(client_id, thread_id, request_id)` built on it (primary turn + the request-keyed parallel lane). Keep `cancel_chat` (2-arg) as the unscoped "stop everything" back-compat entry, so existing callers/tests are untouched. - `channel_web_cancel` no longer falls through to `cancel_session` on a scoped no-op — a stale cancel that matched nothing is a clean no-op. - Plumb an optional `request_id` from the client through: socket `chat:cancel` payload -> `channel.web_cancel` RPC schema/params -> `cancel_chat_scoped`. Regression: tests/web_cancel_request_scoping.rs.
5272c79 to
57e7c6f
Compare
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 48 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughThis PR adds request-scoped cancellation for web-channel chat turns. ChangesRequest-scoped cancellation
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant SocketHandler as chat:cancel handler
participant ChannelWebCancel as channel_web_cancel
participant CancelChatScoped as cancel_chat_scoped
participant TurnRegistry as In-flight turn registry
Client->>SocketHandler: chat:cancel(request_id?)
SocketHandler->>CancelChatScoped: cancel_chat_scoped(client_id, thread_id, request_id)
CancelChatScoped->>TurnRegistry: lookup in-flight primary/parallel turns
TurnRegistry-->>CancelChatScoped: in_flight request_id(s)
CancelChatScoped->>CancelChatScoped: cancel_should_target(requested, in_flight)
alt matches or unscoped
CancelChatScoped->>TurnRegistry: cancel matching turn(s)
CancelChatScoped-->>ChannelWebCancel: Some(cancelled_id)
else stale scoped cancel
CancelChatScoped-->>ChannelWebCancel: None (ignored, logged)
end
ChannelWebCancel-->>SocketHandler: RpcOutcome(cancelled)
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/openhuman/channels/providers/web/ops.rs (1)
1002-1066: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
cancel_chat_scopedreturn value ignores parallel-turn cancellations, causingchannel_web_cancelto misreportcancelled: false.
removed_request_idonly reflects the primary in-flight turn. When a scoped cancel matches only a parallel (forked) turn —cancel_parallel_turn_by_request_idsucceeds and a "chat_error"/cancelled event is actually published for it — the function still returnsOk(None). Downstream,channel_web_cancel(Line 1180-1186) treatscancelled_request_id.is_none() && request_id.is_some()as "matched nothing" and reportscancelled: false, request_id: null, even though the parallel turn was genuinely cancelled. The RPC ack becomes inconsistent with the emitted event.🐛 Proposed fix to fold parallel cancellations into the returned id
let cancelled_parallel = match request_id { Some(rid) => cancel_parallel_turn_by_request_id(thread_id, rid).await, None => cancel_parallel_turns_for_thread(thread_id).await, }; + let cancelled_any = removed_request_id + .clone() + .or_else(|| cancelled_parallel.first().cloned()); + // Emit a cancelled chat_error for each cancelled turn (primary + parallels) // so every interleaved branch's UI is resolved. for request_id in removed_request_id.iter().cloned().chain(cancelled_parallel) { publish_web_channel_event(WebChannelEvent { event: "chat_error".to_string(), client_id: client_id.to_string(), thread_id: thread_id.to_string(), request_id, message: Some("Cancelled".to_string()), error_type: Some("cancelled".to_string()), ..Default::default() }); } - Ok(removed_request_id) + Ok(cancelled_any) }This also fixes the
"request_id": cancelled_request_idfield inchannel_web_cancel's JSON response (Line 1188-1196), which currently omits a cancelled parallel turn's id.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/openhuman/channels/providers/web/ops.rs` around lines 1002 - 1066, `cancel_chat_scoped` only returns the primary in-flight request id, so scoped cancels that hit a parallel turn are reported as if nothing matched. Update `cancel_chat_scoped` to capture the id(s) returned by `cancel_parallel_turn_by_request_id` and include them in the function’s `Ok(...)` result when no primary turn was removed. Keep the existing `publish_web_channel_event` loop intact, and make sure `channel_web_cancel`’s `cancelled_request_id`/`cancelled` response reflects parallel-turn cancellations as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/openhuman/channels/providers/web/ops.rs`:
- Around line 1002-1066: `cancel_chat_scoped` only returns the primary in-flight
request id, so scoped cancels that hit a parallel turn are reported as if
nothing matched. Update `cancel_chat_scoped` to capture the id(s) returned by
`cancel_parallel_turn_by_request_id` and include them in the function’s
`Ok(...)` result when no primary turn was removed. Keep the existing
`publish_web_channel_event` loop intact, and make sure `channel_web_cancel`’s
`cancelled_request_id`/`cancelled` response reflects parallel-turn cancellations
as well.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f9d90808-563d-4889-a269-03206fe482b5
📒 Files selected for processing (8)
src/core/socketio.rssrc/openhuman/channels/providers/web/mod.rssrc/openhuman/channels/providers/web/ops.rssrc/openhuman/channels/providers/web/schemas.rssrc/openhuman/channels/providers/web/types.rstests/channels_web_startup_raw_coverage_e2e.rstests/tools_network_channels_raw_coverage_e2e.rstests/web_cancel_request_scoping.rs
|
CI guardian: ran |
… result cancel_chat_scoped returned only the primary in-flight request id, so a scoped cancel that matched only a parallel (forked) turn — which still cancelled a turn and emitted its cancelled event — returned Ok(None). channel_web_cancel then misreported cancelled: false with a null id even though a turn was genuinely torn down. Fold the cancelled parallel id into the returned value so the RPC ack matches the emitted event. Addresses @coderabbitai on src/openhuman/channels/providers/web/ops.rs.
A scoped web cancel (request_id set) that targeted an autonomous task-dispatcher run was dropped: those runs aren't web-channel turns, so cancel_chat_scoped returned None and channel_web_cancel's `else if request_id.is_some() => false` branch suppressed the cancel_session fallback, leaving the run alive until completion. Add cancel_session_scoped: when a request_id is given it aborts the active run only if its run_id matches, so scoped cancels now reach detached runs while a stale cancel for a superseded request stays a no-op (tinyhumansai#4760). An unscoped stop is unchanged. channel_web_cancel routes its fallback through it. Adds unit tests for the match / mismatch / no-run paths. Addresses @chatgpt-codex-connector on src/openhuman/channels/providers/web/ops.rs.
|
Addressed the outstanding bot review comments in f21f498 (pushed):
|
|
To use Codex here, create a Codex account and connect to github. |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
cd /tmp
rm -rf repo
git clone --depth 50 https://github.com/tinyhumansai/openhuman repo 2>&1 | tail -5
cd repo
git fetch origin pull/4765/head:pr4765 2>&1 | tail -5
git checkout pr4765 2>&1 | tail -5
echo "--- log ---"
git log --oneline -10
echo "--- ops.rs relevant section ---"
rg -n "cancel_chat_scoped|cancel_should_target|cancel_parallel_turn_by_request_id|channel_web_cancel" src/openhuman/channels/providers/web/ops.rsLength of output: 2465 🏁 Script executed: #!/bin/bash
cd /tmp/repo
echo "=== commit 499f4db ==="
git show 499f4db -- src/openhuman/channels/providers/web/ops.rs
echo "=== commit f21f498 ==="
git show f21f498Length of output: 11199 Both fixes look correct and well-targeted.
Both changes preserve the unscoped "stop everything" semantics ( [resolve] |
Review: approveFeature satisfied. The PR scopes web-channel turn cancellation to What holds up:
Note on the issue's "cross-thread" symptom: Minor / non-blocking:
Review threads: all three substantive threads are genuinely addressed — the parallel-turn return-value major (folded into |
YellowSnnowmann
left a comment
There was a problem hiding this comment.
Approving — feature satisfied, no blocker/major issues, review comments addressed in code, CI green. Detailed review in the PR comment above.
What
A still-running turn could cause the user's next turn to die at t=0 with a "cancelled" signature — even after the client had moved on. Reported cases: a client that timed out on a long turn and then sent a new message on the same thread saw the new turn resolve as cancelled at t=0; an isolated re-run of the same prompt worked fine (confirming cross-turn interference, not a prompt issue).
Root cause (verified in code)
cancel_chat(client_id, thread_id)/channel_web_cancelcancel whatever turn is currently inIN_FLIGHT[thread], with norequest_idscoping — the RPC (channel.web_cancel) and socket (chat:cancel) cancel API carried onlyclient_id+thread_id. So when a client times out on request A and then sends request B (which supersedes A on that thread), A's late, thread-wide cancel tears down B — the newer turn — at t=0.channel_web_cancelcompounded it by escalating to the thread-widetask_dispatcher::cancel_sessionwhenevercancel_chatmatched nothing, which would also kill a newer turn.This is exactly the issue's "cancellation isn't scoped to the old request_id." (Not a tinyagents-harness matter: the harness
RunContextcancellation token is minted fresh per run — the missing scoping is here in the web-channel routing.)Fix (minimal)
cancel_should_target(requested, in_flight)— a small pure predicate: a scoped cancel (Some(request_id)) fires only when it targets the request actually in flight; an unscoped cancel (None) still stops whatever is running.cancel_chat_scoped(client_id, thread_id, request_id)built on it — scopes both the primary turn and the request-keyed parallel lane.cancel_chat(2-arg) is kept as the unscoped "stop everything" back-compat entry (Stop button / session teardown), so all existing callers and tests are untouched.channel_web_cancelno longer falls through tocancel_sessionon a scoped no-op — a stale cancel that matched nothing is now a clean no-op instead of a thread-wide teardown.request_idfrom the client through the whole path: socketchat:cancelpayload →channel.web_cancelRPC schema/params →cancel_chat_scoped. Clients that pass the id of the turn they started now cancel exactly that turn; omitting it preserves today's stop-everything behaviour.Net effect: a client giving up cleanly cancels its own server turn, and a prior in-flight turn's stale cancel can no longer kill the next one.
Testing
tests/web_cancel_request_scoping.rspins the scoping decision: unscoped cancels always target the in-flight turn; a scoped cancel fires only for its own request; a stale scoped cancel for a superseded request is a no-op (the newer turn survives). It's an integration test of the pure predicate, so it's fast, deterministic, and independent of any turn/config setup. No local build (disk constrained on this runner); CI compiles/runs it.Closes #4760
Summary by CodeRabbit
New Features
Bug Fixes
Tests