diff --git a/strix/core/runner.py b/strix/core/runner.py index ada35e79..f43b1d7f 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -592,16 +592,10 @@ async def run_strix_scan( ) if not interactive and result is not None: final = getattr(result, "final_output", None) - scan_completed = False - if isinstance(final, str): - try: - parsed = json.loads(final) - scan_completed = bool(isinstance(parsed, dict) and parsed.get("scan_completed")) - except (ValueError, TypeError): - scan_completed = False - elif isinstance(final, dict): - scan_completed = bool(final.get("scan_completed")) - if not scan_completed: + # Lifecycle tools mark the root completed. + async with coordinator._lock: + root_completed = coordinator.statuses.get(root_id) == "completed" + if not root_completed: logger.error( "Scan %s ended without calling finish_scan. The agent " "emitted a text-only turn instead of a lifecycle tool call, " diff --git a/tests/test_runner_teardown.py b/tests/test_runner_teardown.py index 4433c17d..d7e9645d 100644 --- a/tests/test_runner_teardown.py +++ b/tests/test_runner_teardown.py @@ -91,3 +91,31 @@ async def test_a_live_child_is_settled_before_sessions_close( task = child_task["t"] assert task.done(), "the child task was left running past scan teardown" assert task.cancelled(), "the child was not cancelled cleanly on a finish" + + +@pytest.mark.parametrize(("root_status", "logged"), [("completed", False), ("stopped", True)]) +@pytest.mark.asyncio +async def test_missing_finish_is_logged_only_when_root_did_not_complete( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, + caplog: pytest.LogCaptureFixture, + root_status: str, + logged: bool, +) -> None: + _wire_runner(monkeypatch, tmp_path) + coordinator = AgentCoordinator() + + async def _root_ends_with_text(**kwargs: Any) -> Any: + await coordinator.set_status(kwargs["agent_id"], root_status) + return types.SimpleNamespace(final_output="The review is complete.") + + monkeypatch.setattr(runner, "run_agent_loop", _root_ends_with_text) + + await runner.run_strix_scan( + scan_config={"targets": [], "scan_mode": "deep"}, + scan_id="scan-test", + image="img", + coordinator=coordinator, + ) + + assert ("ended without calling finish_scan" in caplog.text) is logged