Skip to content

Commit 3761fb8

Browse files
bai-uipathclaude
andcommitted
fix(claude-code): kill the CLI on a cooperative stop
The cooperative stop ended the message loop but left the CLI running, since closing the SDK's query() stream does not end it. A stopped-early turn kept spending and could keep changing the sandbox while grading ran. Kill the CLI on that stop as the max_turns backstop does, and keep a process handle whenever a stop can fire. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5f4fa9a commit 3761fb8

2 files changed

Lines changed: 41 additions & 7 deletions

File tree

‎src/coder_eval/agents/claude_code_agent.py‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -987,7 +987,7 @@ def capture_stderr(line: str) -> None:
987987

988988
try:
989989
options, transport, effective_model = self._build_claude_query(
990-
user_input, timeout, max_turns, capture_stderr
990+
user_input, timeout, max_turns, capture_stderr, can_stop=should_stop is not None
991991
)
992992
# Set on the state BEFORE the AgentStart emit and any finalize path
993993
# (finalize reads it for cost backfill); stays None if setup crashed.
@@ -1147,6 +1147,7 @@ async def _pump_messages(
11471147
if should_stop is not None and should_stop():
11481148
state.stopped_early_hit = True
11491149
self._log.debug("Cooperative stop requested; ending message loop at this boundary")
1150+
self._kill_transport(self._active_transport)
11501151
break
11511152

11521153
def _build_claude_query(
@@ -1155,11 +1156,14 @@ def _build_claude_query(
11551156
timeout: float | None,
11561157
max_turns: int | None,
11571158
stderr_callback: Callable[[str], None],
1159+
*,
1160+
can_stop: bool = False,
11581161
) -> tuple[ClaudeAgentOptions, SubprocessCLITransport | None, str | None]:
1159-
"""Build the SDK options (+ a timeout-only transport) for one turn.
1162+
"""Build the SDK options (+ a killable transport) for one turn.
11601163
1161-
``transport`` is None unless a ``timeout`` is set: it is pre-constructed
1162-
only so the watchdog can hard-kill the subprocess. ``effective_model`` may
1164+
``transport`` is None unless a timeout, a turn cap or a cooperative stop
1165+
(``can_stop``) can end the turn: it is pre-constructed only so the harness
1166+
can hard-kill the subprocess. ``effective_model`` may
11631167
be None on a DirectRoute with no configured model. ``stderr_callback`` is
11641168
wired in here but owned by ``communicate``.
11651169
"""
@@ -1226,12 +1230,12 @@ def _build_claude_query(
12261230
# For later inspection: captures every field, defaults included.
12271231
self._sdk_options_dump = dump_dataclass(options)
12281232

1229-
# Pre-constructed only under a timeout or turn cap, to retain the subprocess
1230-
# handle for hard-kill. None otherwise, so the SDK uses its own default and
1233+
# Pre-constructed only when the harness may end the turn, to retain the
1234+
# subprocess handle for hard-kill. None otherwise, so the SDK uses its own default and
12311235
# tests can mock query() without a real CLI. Closing the SDK's query()
12321236
# stream does not end the CLI: it never closes the generator it wraps.
12331237
transport: SubprocessCLITransport | None = None
1234-
if timeout is not None or max_turns is not None:
1238+
if timeout is not None or max_turns is not None or can_stop:
12351239
transport = SubprocessCLITransport(prompt=user_input, options=options)
12361240

12371241
return options, transport, effective_model

‎tests/test_agent.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1770,6 +1770,36 @@ async def mock_query(prompt, options, transport=None):
17701770
assert len(turn_record.commands) == 3
17711771

17721772

1773+
@pytest.mark.asyncio
1774+
async def test_claude_agent_cooperative_stop_kills_the_cli():
1775+
"""A cooperative stop with no timeout or cap still kills the CLI instead of leaving it running."""
1776+
agent = ClaudeCodeAgent(parse_agent_config(type=AgentKind.CLAUDE_CODE, permission_mode="acceptEdits"))
1777+
pulled = 0
1778+
passed_transport = []
1779+
1780+
async def mock_query(prompt, options, transport=None):
1781+
nonlocal pulled
1782+
passed_transport.append(transport)
1783+
for n in range(200):
1784+
for message in _api_call(n):
1785+
pulled += 1
1786+
yield message
1787+
1788+
with tempfile.TemporaryDirectory() as tmpdir:
1789+
await agent.start(tmpdir)
1790+
with (
1791+
patch("coder_eval.agents.claude_code_agent.query", mock_query),
1792+
patch.object(ClaudeCodeAgent, "_kill_transport") as kill,
1793+
):
1794+
turn_record = await agent.communicate("loop forever", should_stop=lambda: pulled >= 4)
1795+
1796+
assert passed_transport[0] is not None
1797+
kill.assert_called_once_with(passed_transport[0])
1798+
assert pulled == 4
1799+
assert turn_record.crashed is False
1800+
assert turn_record.max_turns_exhausted is False
1801+
1802+
17731803
@pytest.mark.asyncio
17741804
async def test_claude_agent_max_turns_backstop_ignores_emissions_and_subagent_calls():
17751805
"""Per-block emissions share one API call, and sub-agent calls have their own cap."""

0 commit comments

Comments
 (0)