Repository navigation
fix(node): a cancel before agent/launch answers ends the agent it started - #992
akushonkamen wants to merge 3 commits into
Conversation
…rted NodePool.open_channel registers the channel before it awaits agent/launch, and its except arms covered AcpRemoteError, AcpTimeoutError and AcpConnectionError only. A CancelledError raised while the answer waited passed through all three: the channel stayed in the link's channel table, no agent/cancel went out, no run record existed for a next start to read, and the agent ran on under the machine's daemon. The callers could not end it either, since the endpoint never reached them: _exec_there's try begins after open_channel returns, and the pool's launcher closes the journal and re-raises. Catch the cancel in open_channel: drop the channel, end the endpoint under a shield, which the cancel itself must not cut short, then raise. ChannelEndpoint.end already settles on either answer, the cancel taken or nothing there to cancel, and keeps a cancel for the next link while the link is down; when this computer is stopping, it leaves the run with the machine's daemon, as it does when the cancel lands after the answer. Regressions hold the launch answer for half a second once it is in, one test per entry through open_channel, a cli one and an ACP one, and cancel the turn in that wait: the agent is gone from the machine and the channel from the table. Both are red on the base commit and green here. Co-authored-by: GLM (glm-5.3-flash) <noreply@anthropic.com>
|
Small housekeeping note on the red CI entry: the "pull request title" job failure was from the PR body, not the code - I have edited the PR body replacing those with ASCII equivalents ( |
A cancel that landed before the daemon registered the channel was answered "no agent on channel": ChannelEndpoint.end read that as nothing left to cancel, and the launch then completed with the agent running. The daemon registers the channel only after the spawn and after it has the login environment, which its first launch captures by running the machine user's login shell. The launch request now runs as its own task under asyncio.shield. When the caller is cancelled the answer is still read, and only then is the agent ended through endpoint.end(), so the cancel finds the channel; a refused launch keeps its handling. The channel leaves the table on agent/exit, as for a cancel after the answer, so the two regressions wait for that. A new regression drives the window with a stand-in login shell that takes a second, and two cases on a held fake link pin the order. Co-authored-by: Claude (claude-opus-5-5[1m]) <noreply@anthropic.com>
… tests
Two tests read remote_runs.pending() as soon as the command's pid.txt
appeared, while the run's record is made only once the launch answer
reaches this side, so a late answer failed them ("recorded while it
runs", or an unpack of nothing) and left the agent running. Holding
the answer makes both fail every time; they now wait for the record
first, as tests/test_node_stderr_replay.py does on main.
Co-authored-by: Claude (claude-opus-5-5[1m]) <noreply@anthropic.com>
|
Thanks -- merging this one in maintainer mode, with two commits on top of yours in the branch. I have not rewritten your commit; both sit after it. Why. The arm sent It is not sub-millisecond. The first launch of each daemon captures the login environment by running the machine user's login shell (
New regression:
Verification, Docker Linux (Debian bookworm, Python 3.12.12,
The description now records this; it is the squash body, edited so it describes this head rather than the previous one. |
Summary
A cancel that lands while
open_channelstill waits for the daemon'sagent/launchanswer left the agent running on the machine, with the channel stuck in the table and no way for the caller to end it (#990).NodePool.open_channelregisters the channel intonode.channels(raven/node/client.py:588) before it awaitsagent/launch(raven/node/client.py:591). Its threeexceptarms (raven/node/client.py:592-604) coverAcpRemoteError,AcpTimeoutErrorandAcpConnectionErroronly, so aCancelledErrorraised while the answer waited passed through all three: the channel stayed in the link's channel table, noagent/cancelever went out, and the endpoint never reached the caller - the cli launcher'strybegins only afteropen_channelreturns (raven/agent/subagent/backends/cli_agent.py:681), and the ACP launcher closes the journal and re-raises (raven/acp_client/pool.py:476-481). The agent went on running under the machine's daemon, and no run record existed for a later start to pick up.NodePool._launch_agent(raven/node/client.py:606), awaited underasyncio.shield(raven/node/client.py:589-599). When the caller is cancelled, the arm still awaits that answer, then asksendpoint.end()and re-raises. The daemon answersagent/launchonly after it has registered the channel (raven/node/server.py:1495-1498); a cancel sent before that is answeredno agent on channel(raven/node/server.py:1477), whichendreads as nothing left to cancel, so waiting for the answer is what lets theagent/cancelfind the channel. A refused or failed launch keeps its handling (NodeError, the channel popped) and the arm re-raises the cancel.ChannelEndpoint.endkeeps a cancel for the next link while the link is down; when this computer is stopping, it leaves the run with the machine's daemon, as it already does when the cancel lands after the answer. The channel leaves the link's table onagent/exit, as it does for a cancel after the answer._hold_launch_answershelper (tests/test_subagent_acp_remote.py:226, imported by the cli test attests/test_subagent_cli_remote.py:42) keeps theAGENT_LAUNCHanswer half a second once it is in; the cancel waits for theansweredevent first, so by the time it lands the answer is already local and the daemon has necessarily registered its channel - the window is hit for certain. Each test then asserts the agent process is gone from the machine and waits for the channel to leavenode.channels. One test per entry throughopen_channel:tests/test_subagent_cli_remote.py:349(cli) andtests/test_subagent_acp_remote.py:732(ACP). Both are red on the base commit and green here.raven/node/server.py:1220,capture_login_env), then spawns, and only then registers the channel; a daemon exits afterIDLE_EXIT_S(600 s) without activity, so a later dispatch can be a first launch again. The stand-in ssh runs everything underSHELL=/bin/sh, which the capture skips, sotests/test_subagent_cli_remote.py:370switches it to bash with a one-second~/.bash_profile, cancels while the daemon is in that login shell, waits for the daemon's task record and requires that no agent is left. Two cases on a held fake link (tests/test_node_client.py:590,:606) pin the order:agent/cancelonly after the answer, and none for a refused launch.test_a_cli_run_left_on_the_machine_when_this_computer_stops_is_picked_up_by_the_next_start(tests/test_subagent_cli_remote.py:857) andtest_a_cancel_while_this_computer_goes_on_ends_the_command_and_keeps_no_record(:937) assertedremote_runs.pending()the momentpid.txtappeared, while the record is made once the launch answer arrives; with the answer held, both fail every time. They now wait for the record first, astests/test_node_stderr_replay.pydoes onmain.Edges recorded and left out of scope:
attachcarries the same shape (raven/node/client.py:644registers,Acp*arms only) but no cancel arm; after a cancel there the agent still has a run record on the daemon side that a later attach can find, a different failure mode. Untouched.end's stays branch behaves as it does post-answer (the run stays with the machine's daemon), so the missing local run record in that window is unchanged. Stated for the record.Type
Verification
Docker Linux (Debian bookworm, Python 3.12.12,
--init), at this head and with this head merged into currentmain(edb00f54):Red -> green against the base commit:
test_a_cancel_while_the_launch_answer_waits_ends_the_agent_too,test_a_cancel_while_the_acp_launch_answer_waits_ends_the_agent_tooandtest_a_cancel_while_the_daemon_still_prepares_the_launch_ends_the_agent_tooeach fail there with the agent left under the daemon (1 failed, 1 error), and the twotests/test_node_client.pycases fail there too; all five pass here.Removing one construct of the arm at a time turns tests red - the shield 5, the
endpoint.end()4, waiting for the answer 3, theNodeErrorsuppression 1 - overtests/test_node_client.pyand the four machine cancel tests (89 tests).With the launch answer held, the two record-reading tests fail without the wait and pass with it.
tests/test_node_client.py,tests/test_subagent_cli_remote.py,tests/test_subagent_acp_remote.py,tests/test_node_server.py,tests/test_node_relay.py,tests/test_node_bridge.py,tests/test_remote_file_tools.py,tests/test_resume_remote.py,tests/test_cli_ops_connection_commands.py: 413 passed, 1 skipped at this head; merged intomain, withtests/test_node_stderr_replay.pyandtests/test_rpc_machines.pyadded, 478 passed, 1 skipped in each of three runs.Native Windows, Python 3.12.13:
tests/test_node_client.py48 passed, 37 skipped (its machine tests need/bin/sh).Gates over
origin/main...HEAD: pre-commit,scripts/check_commit_messages.py, commitlint,scripts/check_source_language.py,scripts/check_large_files.py,git diff --check,lint-imports(10 contracts kept),ty check raven/node.Relevant tests pass locally
Relevant lint / type checks pass locally
User-facing docs or screenshots are updated when needed
Risk
endpoint.end, only the agent that call's own launch started, the path the code already takes elsewhere; no new reach.CancelledErroras before. A cancel that lands during a launch now waits for the daemon's answer before it returns: milliseconds normally, the login shell's run time on a daemon's first launch, and at most the launch's own budget (_LAUNCH_TIMEOUT_S, 60 s) when the daemon does not answer; at shutdownSubagentManager.cancel_allalready bounds its wait for cancelled runs.Related Issues
Fixes #990