Repository navigation
[fix] Stop ending a displaced turn as a user Stop [#7760] - #7775
xubenqiang wants to merge 1 commit into
Conversation
The alive watchdog aborted every run with `USER_STOP_ABORT_REASON` when the heartbeat answered `is_current_turn: false`. That answer means the platform took the turn away: a steer opened a new turn, a kill or the API's watchdog ended it. It only stood in for a real Stop because unscoped runs had no direct delivery, and every real Stop now arrives as a durable `POST /cancel` command. So the heartbeat path gets its own label, `TURN_DISPLACED_ABORT_REASON`. The turn still parks (a steer inherits the warm environment), but its ending is no longer written down as a cancellation: the backstop persists an `execution_lost` error instead of a `cancelled` done, and the envelope is not rewritten into the Stop shape. Tests: the five server tests that drove a "user Stop" through the heartbeat now deliver it as the control channel does, through the run's registered Stop handle, and a new test pins the displaced ending. A control-channel test and an HTTP test pin that a run with no project scope is stopped by `/cancel`. The stale comment claiming such a run is not registered is corrected.
There was a problem hiding this comment.
Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.
An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing. Review the Copilot code review billing settings.
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Abort signal classification services/runner/src/sessions/stop-signal.ts, services/runner/src/engines/sandbox_agent/engine.ts, services/runner/tests/unit/turn-displaced-abort.test.ts |
A displacement abort label and predicate are added. Cooperative-cancel classification includes displacement. Documentation and tests cover parking behavior for settled cancellations. |
Direct cancellation for unscoped runs services/runner/src/server.ts, services/runner/tests/unit/control-command-apply.test.ts, services/runner/tests/integration/server-smoke.test.ts |
Server comments describe execution registration as unconditional. Unit and integration tests exercise /cancel for registered executions without project scope. |
Displacement outcomes services/runner/src/server.ts, services/runner/tests/unit/server.test.ts |
The watchdog labels no-longer-current turns as displaced. Settled and thrown displaced runs persist as execution_lost; tests distinguish these outcomes from user cancellations. |
Priority:
Estimated code review effort: 3 (Moderate) | ~20 minutes
Severity of issue fixed: Medium
Possibly related PRs
- Agenta-AI/agenta#7687: Both PRs address unscoped-run handling at different stages: sandbox teardown and direct cancellation.
- Agenta-AI/agenta#7476: Both PRs cover stop handling during cold sandbox acquisition; this PR distinguishes heartbeat displacement from a user Stop.
Merge Risk: 
Minimal · up to aebed
-
The changed stop and displacement paths have no identified merge-blocking issue. The PR is ready to merge after normal checks.
Pre-merge checks: 
4 · 
1
-
Failed checks: 1 inconclusive Passed checks: 4 passed-
Full details: Title check
-
Explanation
The title names the main behavior change: a displaced turn should no longer be recorded as a user Stop. It matches the summary, which adds a separate displacement label and changes server handling. The title cites an issue number, but that is not noise that hides the change. The PR objectives cite a different PR number (
#7775) than the title (#7760), which is a metadata mismatch to note but does not affect the title's relevance to the changeset.
Full details: Out of Scope Changes check
-
Explanation
The changed files map to [
#7760].sessions/stop-signal.tsadds the displaced-turn label and its predicate.server.tsuses the label in the watchdog and in the two ending branches, and documents unconditional registration.engine.tschanges onlyshouldParkdocumentation, per the change summary.server.test.tsredirects five existing user-Stop tests to the registered Stop handle. Their assertions are unchanged. This supports the change, because the heartbeat no longer delivers a user Stop. New tests cover the label, the unscoped/cancelpath, and the cold-acquire displacement. The PR adds no dependency, wire field, or schema change. The PR description's image link is not a code change. No unrelated change was identified in the files and summaries reviewed. I readstop-signal.ts, its test file, and the import region ofserver.tsdirectly. The other files were judged from the change summary.
-
Full details: Linked Issues check
-
Explanation
[
#7760] Objective 1, real user Stop reaches unscoped runs through POST /cancel. The PR saysregisterExecutionruns unconditionally after admission and thatfindExecutionmatches an unknown project. It also says the PR adds tests incontrol-command-apply.test.tsandserver-smoke.test.ts. I did not read the registration code or these tests. Theserver.tsread was truncated at 16000 bytes. This objective is unverified at the reviewed head.[
#7760] Objective 2, a heartbeat displacement ends with a reason other than user Stop. Verified at head:sessions/stop-signal.tsdefinesTURN_DISPLACED_ABORT_REASONandisTurnDisplacedAbort, and includes displacement inisCooperativeCancelAbort.server.tsimports both symbols.tests/unit/turn-displaced-abort.test.tschecks label identity, separation from the user-Stop label, and parking on a settled cancel. The body of the watchdogonInterruptedcallback was not read. Its use of the new label rests on the PR description and the change summary.[
#7760] Partial result. The PR states that a displaced turn which already emitted its owndonekeepsdone.stopReason: cancelled. The issue asks that the transcript stop saying the user stopped the turn. The PR says a record-contract change across runner, API and UI is required and is not made here. The issue does not state that this change is in scope, but the case is unresolved.No test was checked for the issue's requested takeover ending on a turn that already emitted
done.Resolution
Confirm at the reviewed head: (1) the code path that registers a run with no project scope, and the test that sends
POST /cancelto such a run and expects a user-stop abort; (2) theonInterruptedbody inserver.tsthat aborts withTURN_DISPLACED_ABORT_REASON. Decide whether the issue requires a non-cancelledrecorded ending for a displaced turn that already emitteddone. The PR defers that case, so a reviewer must accept the deferral or require the record change.
-
Finishing Touches
-
🧪 Generate unit tests (beta)
-
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands.
mmabrouk
left a comment
There was a problem hiding this comment.
thanks for the pr, and for the careful write-up. typecheck, the focused unit tests and the new /cancel integration test all pass for me on aebed70. i found one thing that needs a change before qa.
the pr assumes every real user stop now arrives on POST /cancel. two live stop paths still reach the runner only through the heartbeat:
api/oss/src/core/channels/commands.pydispatch_stop(the!stopcommand in slack and telegram) callsstreams_service.commandwith no data andforce=False. that resolves toCommandMode.cancel, which runs_displace_turnsand clears the alive lock. no durable command is created.web/oss/src/components/AgentChatSlice/AgentChatPanel.tsxcloses a running tab withcommandSessionStream({sessionId, projectId}), the same cancel mode. the comment there says closing a running tab is a stop.
with this change, both of those user stops get TURN_DISPLACED_ABORT_REASON. if the stop lands during a cold acquire, the runner now writes an execution_lost error instead of a cancelled ending, and the result envelope is no longer the stop shape. so a stop the user did perform gets recorded as a lost turn, which is the opposite of what #7760 wants.
smallest fix: send these two callers through the durable stop (SessionCommandsService cancel on the api side, cancelSessionExecution on the web side), with one test each. if you would rather keep this pr runner-only, say so here and a maintainer can pick the route.
| // aborts through the execution registry with `USER_STOP_ABORT_REASON`; what is left on | ||
| // this path is displacement, and saying otherwise writes a Stop nobody performed into | ||
| // the transcript. See `sessions/stop-signal.ts`. | ||
| controller.abort(TURN_DISPLACED_ABORT_REASON); |
There was a problem hiding this comment.
this path still carries real user stops today: the channel !stop command and closing a running chat tab both use the stream cancel mode, which only clears the alive lock. until those callers use the durable stop, labelling this as a displacement records their stop as execution_lost on the cold-acquire path.
|
thanks — you are right, and I checked both: I would rather keep this PR runner-only, as you offered, and let you pick the route for those two callers. I am happy to take that follow-up as its own PR — One thing worth knowing for that follow-up: |
|
Thanks for the careful review. I would rather keep this PR runner-only: it changes how the runner treats a displaced turn, and I do not want to widen it into the API and web command routing in the same change. So the two callers you listed ( |
mmabrouk
left a comment
There was a problem hiding this comment.
thanks, keeping this runner-only makes sense. no follow-up pr is needed for those two callers: #7835 has since merged and moved both of them to the durable cancel route. dispatch_stop in api/oss/src/core/channels/commands.py now calls commands_service.request_cancel, and the tab-close path in AgentChatPanel.tsx now calls cancelSessionExecution.
so my earlier finding is resolved upstream, and i have no other change to ask for on aebed70. the next step is on our side: running the held required workflows.
Summary
The runner's alive watchdog aborted a turn with the user-Stop label whenever a heartbeat answered
is_current_turn: false. That answer is not a user pressing Stop: it means the platform took the turn away — a steer opened a new turn on the session, a kill ended it, or the API's own watchdog declared it lost. All three ended the same way, labelled as a user Stop, so the runner wrote down a Stop nobody performed and the cause of a lost turn was invisible.The old comment in
sessions/stop-signal.tsexplains why it was written that way: before the durable command plane there was no other route for a Stop to reach a run, and a shared label was safe rather than merely tolerable, because a steer wants the warm environment for the turn it starts. That is no longer the case. Every real Stop now arrives as a durable command onPOST /cancel, routed to the turn's binding, and the applier aborts the run directly withUSER_STOP_ABORT_REASON(sessions/control-channel.ts). What is left on the heartbeat path is displacement, so it gets its own label.One correction to the issue: it says a run with no project scope is not registered for a direct Stop. It is.
registerExecutionis called unconditionally after admission, and a stored unknown project matches any caller infindExecution, soPOST /cancelalready reaches such a run today. The comment above that block said the opposite, and that comment is what the release review read. This PR corrects the comment and adds the tests that pin the real behaviour instead of changing code that already worked.What changed
sessions/stop-signal.ts: newTURN_DISPLACED_ABORT_REASONandisTurnDisplacedAbort. It is part ofisCooperativeCancelAborton purpose — a displaced turn's harness settled, and its environment is worth keeping warm for the turn that took the session over (the steer case the old comment argued for); a kill destroys the pool entry by key either way. Only the recorded ending changes.server.ts: the watchdog'sonInterruptedaborts with the new label, and both ending branches stop treating a displaced turn as a user Stop. When the run left no terminal record of its own (a displacement that aborted a cold acquire), the backstop persists anexecution_losterror instead of acancelleddone, and the envelope is not rewritten into theok: true/stopReason: cancelledshape a real Stop earns. A turn that already emitted its owndonekeeps that ending: its harness cancel path is shared with a Stop, so itsdone.stopReasonis stillcancelled. Naming the take-over in that record needs a record-contract change across the runner, the API and the UI, which this PR deliberately does not make.engine.tsandserver.tsthat called the heartbeat interrupt the Stop call site.Testing
Verified locally
Windows 11, Node 24.21.0, pnpm 10.30.0, in
services/runner:pnpm run typecheck— clean.pnpm exec vitest run --project unit tests/unit/turn-displaced-abort.test.ts tests/unit/control-command-apply.test.ts tests/unit/session-alive-interrupt.test.ts— 3 files, 48 tests pass.pnpm exec vitest run --project unit tests/unit/server.test.ts— 67 tests pass.pnpm exec vitest run --project integration tests/integration/server-smoke.test.ts— 9 tests pass.pnpm exec vitest run --project acceptance— 2 files, 23 tests pass.21d79a07without this change (git stash, same command, same machine). They are all Windows-only: theinprocess/*suites spawnbash/setsid, several assert POSIX mode bits or symlink behaviour, and one asserts a/-separated path. CI runs them on ubuntu. No failure was added or removed by this branch.Added or updated tests
tests/unit/turn-displaced-abort.test.ts(new): the label is not a user Stop, cannot be forged from text, still counts as a cooperative cancel, and its settled cancellation still parks.tests/unit/server.test.ts: one new test — a heartbeat displacement during a cold acquire ends as anexecution_losterror, with nocancelledending and no Stop envelope. The five existing tests that drove a "user Stop" through the heartbeat now deliver it through the run's registered Stop handle, the wayPOST /canceldoes; their assertions are unchanged, so the Stop behaviour they covered is still covered.tests/unit/control-command-apply.test.ts: a run registered with no project scope is stopped by a cancel command for its project, with the user-Stop label.tests/integration/server-smoke.test.ts:POST /cancelanswers 202 for such a run and aborts it.QA follow-up
Demo
N/A — runner behaviour only, no UI change. The capture below is the local verification listed above, in case you prefer to read it as output:
Checklist
services/runnerhas no formatter or linter gate (the styling workflow coversapi/,sdks/python/andweb/);pnpm run typecheckis its static check and it is cleanCloses #7760