diff --git a/argus_skill/adapters/agent_cli_backend/_exec_spawn.py b/argus_skill/adapters/agent_cli_backend/_exec_spawn.py index 6d9430232..533ffa221 100644 --- a/argus_skill/adapters/agent_cli_backend/_exec_spawn.py +++ b/argus_skill/adapters/agent_cli_backend/_exec_spawn.py @@ -29,6 +29,7 @@ is_execution_host_startup_error, is_model_catalog_startup_error, result_has_pre_provider_refusal, + terminal_failure_diagnostic, ) from ...core.runner_receipts import is_provider_turn_cap_receipt from ...core.secret_guard import redact_secrets_text @@ -283,9 +284,8 @@ def spawn_and_finish(ctx: "_ExecContext", cli_options: Any) -> RunnerResult: or getattr(cli_result, "fatal_error", None) or int(getattr(cli_result, "exit_code", 0) or 0) != 0 ) - stderr_lines = list(getattr(cli_result, "stderr_lines", None) or []) fatal_error = str(getattr(cli_result, "fatal_error", "") or "") - failure_text = "\n".join([fatal_error, *map(str, stderr_lines)]).strip() + failure_text = terminal_failure_diagnostic(cli_result) safe_failure_text = redact_secrets_text( failure_text, known_values=backend._known_secret_values, diff --git a/argus_skill/adapters/agent_cli_backend/_result.py b/argus_skill/adapters/agent_cli_backend/_result.py index b0c140bb6..d491d6b70 100644 --- a/argus_skill/adapters/agent_cli_backend/_result.py +++ b/argus_skill/adapters/agent_cli_backend/_result.py @@ -19,7 +19,7 @@ from ...core.role_decision import extract_role_decisions from ...core.runner_errors import ( is_execution_host_startup_error, - is_model_catalog_startup_error, + terminal_failure_diagnostic, ) from ...core.runner_receipts import is_provider_turn_cap_receipt from ...core.stop_kinds import ( @@ -363,35 +363,25 @@ def translate_result( {**row, "model": authoritative_usage_model} for row in model_usage ] - raw_fatal_error = str(getattr(cli_result, "fatal_error", "") or "").strip() - fatal_error = _normalize_fatal_error(cli_result.fatal_error) - if ( - getattr(cli_result, "turn_failed", False) - and not fatal_error - ): - fatal_error = "\n".join( - map(str, getattr(cli_result, "stderr_lines", None) or []) - ).strip() or "backend reported a failed turn" - failure_diagnostic = raw_fatal_error - authoritative_local_stop = ( - is_provider_turn_cap_receipt(raw_fatal_error) - or is_execution_host_startup_error(raw_fatal_error) + failure_diagnostic = terminal_failure_diagnostic(cli_result) + fatal_error = _normalize_fatal_error(failure_diagnostic) or None + stop_kind = normalize_stop_kind(getattr(cli_result, "stop_kind", None)) or _raw_backend_stop_kind( + fatal_error=failure_diagnostic, exit_code=cli_result.exit_code, ) - if not authoritative_local_stop and ( - getattr(cli_result, "turn_failed", False) - or int(getattr(cli_result, "exit_code", 0) or 0) != 0 + # Preserve the established Manager timeout backoff for its final explicit + # reconnect/429 notice. This narrow scheduling hint must not participate + # in auth replay, overwrite the receipt, or override an operator stop. + if ( + stop_kind == "transient_error" + and failure_diagnostic.casefold().startswith( + "external interrupt: manager turn wall-clock limit reached" + ) ): - stderr_diagnostic = "\n".join( - map(str, getattr(cli_result, "stderr_lines", None) or []) - ).strip() - if is_model_catalog_startup_error(stderr_diagnostic): - # CLI startup can provide only a generic terminal receipt while - # stderr explains that model discovery failed before any turn. - fatal_error = stderr_diagnostic - if stderr_diagnostic and stderr_diagnostic not in failure_diagnostic: - failure_diagnostic = "\n".join( - part for part in (failure_diagnostic, stderr_diagnostic) if part - ) + latest = next((str(line).strip() for line in reversed( + getattr(cli_result, "stderr_lines", None) or [] + ) if str(line).strip()), "") + if latest.casefold().startswith("reconnecting") and has_http_status(latest, {429}): + stop_kind = "provider_cooldown" return RunnerResult( exit_code=cli_result.exit_code, agent_messages=list(cli_result.agent_messages or []), @@ -400,13 +390,7 @@ def translate_result( stderr_lines=list(cli_result.stderr_lines or []), thread_id=cli_result.thread_id or resume_thread_id, fatal_error=fatal_error, - stop_kind=( - normalize_stop_kind(getattr(cli_result, "stop_kind", None)) - or _raw_backend_stop_kind( - fatal_error=failure_diagnostic, - exit_code=cli_result.exit_code, - ) - ), + stop_kind=stop_kind, input_tokens=input_tokens, cached_input_tokens=cached_input_tokens, cache_write_tokens=raw_usage.cache_write_tokens, diff --git a/argus_skill/agent_cli/_event_consumers.py b/argus_skill/agent_cli/_event_consumers.py index 794a8b6ec..5498d4c38 100644 --- a/argus_skill/agent_cli/_event_consumers.py +++ b/argus_skill/agent_cli/_event_consumers.py @@ -315,7 +315,11 @@ def _consume_codex_event( event_type = event.get("type") if event_type == "thread.started": thread_id = event.get("thread_id", thread_id) + elif event_type == "turn.started" and not turn_failed: + fatal_error = None elif event_type == "item.completed": + if not turn_failed: + fatal_error = None item = event.get("item", {}) if not isinstance(item, dict): return thread_id, turn_completed, turn_failed, fatal_error @@ -335,12 +339,14 @@ def _consume_codex_event( turn_completed = True elif event_type == "turn.failed": turn_failed = True + if not is_execution_host_startup_error(fatal_error): + fatal_error = "Backend reported a failed turn." err = event.get("error", {}) if isinstance(err, dict): maybe_msg = err.get("message") - if isinstance(maybe_msg, str) and not is_execution_host_startup_error(fatal_error): + if isinstance(maybe_msg, str) and maybe_msg.strip() and not is_execution_host_startup_error(fatal_error): fatal_error = maybe_msg - elif event_type == "error" and fatal_error is None: + elif event_type == "error" and not turn_failed: maybe_msg = event.get("message") if isinstance(maybe_msg, str): fatal_error = maybe_msg diff --git a/argus_skill/agent_cli/_run_exec.py b/argus_skill/agent_cli/_run_exec.py index 459feffce..0bab54220 100644 --- a/argus_skill/agent_cli/_run_exec.py +++ b/argus_skill/agent_cli/_run_exec.py @@ -75,6 +75,7 @@ class _StreamState: fatal_error: str | None = None provider_turns: int = 0 provider_turn_cap_hit: bool = False + model_progress_observed: bool = False tool_activity_observed: bool = False usage_model: str = "" watchdog_terminated: bool = False @@ -560,6 +561,11 @@ def check_wall_clock_limit() -> bool: if event is None: continue state.json_event_count += 1 + if event.get("type") in { + "turn.started", "turn.completed", "turn.failed", + "item.started", "item.updated", "item.completed", + }: + state.model_progress_observed = True if ( provider_turn_cap > 0 and not state.watchdog_terminated @@ -769,7 +775,7 @@ def _finalize_turn_result( if state.watchdog_terminated: state.turn_failed = True - if state.watchdog_reason and state.fatal_error is None: + if state.watchdog_reason: state.fatal_error = state.watchdog_reason elif state.turn_completed and not state.turn_failed: state.fatal_error = None @@ -787,7 +793,11 @@ def _finalize_turn_result( # authoritative completion event. Preserve stderr when available # so configuration failures still retain their concrete diagnosis. state.turn_failed = True - state.fatal_error = _incomplete_turn_error(state.stderr_lines) + state.fatal_error = ( + "Agent CLI exited without completing a model turn." + if state.model_progress_observed + else _incomplete_turn_error(state.stderr_lines) + ) return AgentRunResult( command=command, @@ -805,6 +815,7 @@ def _finalize_turn_result( fatal_error=state.fatal_error, provider_turns=state.provider_turns, provider_turn_cap_hit=state.provider_turn_cap_hit, + model_progress_observed=state.model_progress_observed, tool_activity_observed=state.tool_activity_observed, usage_model=state.usage_model, orphan_process_group_id=state.orphan_process_group_id, diff --git a/argus_skill/agent_cli/models.py b/argus_skill/agent_cli/models.py index fc925b3c3..504b3af4c 100644 --- a/argus_skill/agent_cli/models.py +++ b/argus_skill/agent_cli/models.py @@ -26,6 +26,7 @@ class AgentRunResult: # keeps the work and continues in a fresh session from the checkpoint. provider_turns: int = 0 provider_turn_cap_hit: bool = False + model_progress_observed: bool = False tool_activity_observed: bool = False usage_model: str = "" orphan_process_group_id: int = 0 diff --git a/argus_skill/core/runner_errors.py b/argus_skill/core/runner_errors.py index 02583f3f3..7dc371905 100644 --- a/argus_skill/core/runner_errors.py +++ b/argus_skill/core/runner_errors.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from typing import Any _MISSING_RESUME_TARGET = "No session, task, or name matched" @@ -30,6 +31,44 @@ ) +def terminal_failure_diagnostic(result: Any) -> str: + """Select one current diagnostic; stderr remains a separate history. + + Concrete terminal receipts win. Generic process-exit receipts may use the + latest startup diagnostic only when no model/tool progress was observed. + This also supports older/external runners without diagnostic provenance. + Never combine old stderr with the current failure for control decisions. + """ + fatal = str(getattr(result, "fatal_error", None) or "").strip() + failed = bool(getattr(result, "turn_failed", False) + or int(getattr(result, "exit_code", 0) or 0) != 0 or fatal) + if getattr(result, "turn_completed", False) and not getattr(result, "turn_failed", False) and int(getattr(result, "exit_code", 0) or 0) == 0: + return "" + generic = (not fatal or fatal.casefold() == "process exited" or fatal == "Agent CLI exited without completing a model turn." + or bool(re.fullmatch(r"Process exited with code -?\d+ before turn completion\.", fatal))) + if not generic: + return fatal + progress = bool( + getattr(result, "provider_turns", 0) + or getattr(result, "model_progress_observed", False) + or getattr(result, "tool_activity_observed", False) + or getattr(result, "agent_messages", None) + or any(e.get("type") in {"turn.started", "turn.completed", "turn.failed", + "item.started", "item.updated", "item.completed"} + for e in (getattr(result, "json_events", None) or []) if isinstance(e, dict)) + ) + if progress: + return (fatal or "Backend exited after progress without a terminal diagnostic.") if failed else "" + # Startup has no model turn to recover within. The last nonempty line is + # the best available evidence; do not search backwards for a desired code. + for line in reversed(getattr(result, "stderr_lines", None) or []): + if str(line).strip(): + text = str(line).strip() + # Some providers reject startup with exit 0 and no turn receipt. + return text if failed or is_pre_provider_refusal_error(text) else "" + return fatal + + def is_execution_host_startup_error(value: object) -> bool: """Recognize the Codex runtime receipt for an unavailable execution host. @@ -93,30 +132,19 @@ def is_unrecoverable_resume_error(value: object) -> bool: def result_has_missing_resume_target(result: Any) -> bool: - parts = [ - getattr(result, "fatal_error", ""), - *(getattr(result, "stderr_lines", None) or []), - ] - return is_missing_resume_target_error("\n".join(map(str, parts))) + return is_missing_resume_target_error(terminal_failure_diagnostic(result)) def result_has_unrecoverable_resume_state(result: Any) -> bool: - parts = [ - getattr(result, "fatal_error", ""), - *(getattr(result, "stderr_lines", None) or []), - ] - return is_unrecoverable_resume_error("\n".join(map(str, parts))) + return is_unrecoverable_resume_error(terminal_failure_diagnostic(result)) def result_has_pre_provider_refusal(result: Any) -> bool: - parts = [ - getattr(result, "fatal_error", ""), - *(getattr(result, "stderr_lines", None) or []), - ] - return is_pre_provider_refusal_error("\n".join(map(str, parts))) + return is_pre_provider_refusal_error(terminal_failure_diagnostic(result)) __all__ = [ + "terminal_failure_diagnostic", "is_execution_host_startup_error", "is_missing_resume_target_error", "is_model_catalog_startup_error", diff --git a/argus_skill/provider_integrations/authorization_retry.py b/argus_skill/provider_integrations/authorization_retry.py index 591582b4a..ac7b31c6e 100644 --- a/argus_skill/provider_integrations/authorization_retry.py +++ b/argus_skill/provider_integrations/authorization_retry.py @@ -9,7 +9,7 @@ from typing import Any from ..core.http_status import has_http_status -from ..core.runner_errors import is_execution_host_startup_error +from ..core.runner_errors import is_execution_host_startup_error, terminal_failure_diagnostic from ..core.runner_receipts import is_provider_turn_cap_receipt from ..core.secret_guard import redact_secrets_text from ..tools.capability_vault import read_codex_provider_config @@ -160,15 +160,9 @@ def _unauthorized_cause(result: Any) -> str: ) if not failed: return "" - candidates = [ - getattr(result, "fatal_error", None), - *reversed(list(getattr(result, "stderr_lines", None) or [])), - ] - for candidate in candidates: - text = str(candidate or "").strip() - if has_http_status(text, {401}): - return text - return "" + text = terminal_failure_diagnostic(result) + return text if has_http_status(text, {401}) else "" + AUTHORIZATION_RETRY_OWNER = AuthorizationRetryOwner() diff --git a/tests/test_terminal_diagnostic_regression.py b/tests/test_terminal_diagnostic_regression.py new file mode 100644 index 000000000..76d738d6b --- /dev/null +++ b/tests/test_terminal_diagnostic_regression.py @@ -0,0 +1,206 @@ +"""Current failure semantics must not depend on recovered stderr history.""" + +from types import SimpleNamespace + +import pytest + +from argus_skill.adapters.agent_cli_backend import AgentCliBackend +from argus_skill.adapters.agent_cli_backend._result import UsageAccumulator, translate_result +from argus_skill.agent_cli.models import AgentRunResult +from argus_skill.core.models import RunnerOptions +from argus_skill.core.runner_errors import result_has_pre_provider_refusal +from argus_skill.provider_integrations.authorization_retry import _unauthorized_cause + + +@pytest.mark.parametrize( + "terminal,kind", + [ + ("HTTP 503 Service Unavailable", "transient_error"), + ("stream disconnected before completion: idle timeout waiting for SSE", "transient_error"), + ("HTTP 429 Too Many Requests", "provider_cooldown"), + ("External interrupt: daemon stop requested", "daemon_shutdown"), + ("Provider turn cap reached: allowance 40", "backend_unavailable"), + ], +) +@pytest.mark.parametrize( + "history", + [ + "HTTP 401 Unauthorized (recovered earlier)", + "error: failed to load models: HTTP 401 Unauthorized (recovered earlier)", + ], +) +def test_terminal_failure_is_shared_by_translation_auth_and_accounting( + monkeypatch, tmp_path, terminal, kind, history +): + backend = AgentCliBackend(backend="codex") + raw = AgentRunResult( + command=["codex"], + exit_code=1, + turn_failed=True, + fatal_error=terminal, + stderr_lines=[history], + ) + monkeypatch.setattr(backend._runner, "run_exec", lambda **kwargs: raw) + result = backend.run_exec( + prompt="fixture", options=RunnerOptions(working_dir=str(tmp_path)), run_label="engineer-r1" + ) + assert result.stop_kind == kind + assert result.fatal_error == terminal + assert result.stderr_lines == [history] + assert not backend._auth_failure_detected + assert not _unauthorized_cause(raw) + assert not result_has_pre_provider_refusal(raw) + + +@pytest.mark.parametrize("fatal", [None, "Process exited with code 1 before turn completion."]) +@pytest.mark.parametrize( + "diagnostic,kind,auth", + [ + ("HTTP 401 Unauthorized", "permanent_error", True), + ("HTTP 503 Service Unavailable", "transient_error", False), + ("error: failed to load models: HTTP 401 Unauthorized", "permanent_error", True), + ], +) +def test_startup_failure_uses_latest_diagnostic(fatal, diagnostic, kind, auth): + raw = AgentRunResult( + command=["codex"], + exit_code=1, + turn_failed=True, + fatal_error=fatal, + stderr_lines=["HTTP 401 recovered", diagnostic], + ) + result = translate_result( + raw, resume_thread_id=None, copilot_usage=None, usage_accumulator=UsageAccumulator() + ) + assert result.stop_kind == kind + assert bool(_unauthorized_cause(raw)) is auth + + +def test_completed_turn_does_not_resurrect_old_auth(): + raw = AgentRunResult( + command=["codex"], exit_code=0, turn_completed=True, stderr_lines=["HTTP 401 Unauthorized"] + ) + assert not _unauthorized_cause(raw) + assert not result_has_pre_provider_refusal(raw) + + +def test_missing_terminal_message_does_not_keep_recovered_event(): + from argus_skill.agent_cli.agent_cli_runner import AgentCliRunner + + runner = AgentCliRunner(agent_bin="codex", backend="codex") + _, _, failed, error = runner._consume_codex_event( + event={"type": "turn.failed", "error": {}}, + thread_id=None, + agent_messages=[], + turn_completed=False, + turn_failed=False, + fatal_error="HTTP 401 Unauthorized (recovered earlier)", + ) + assert failed + assert "401" not in str(error) + + +def test_generic_exit_after_progress_cannot_be_startup_auth(): + raw = SimpleNamespace( + exit_code=1, + turn_failed=True, + fatal_error="Process exited with code 1 before turn completion.", + stderr_lines=["error: failed to load models: HTTP 401 recovered"], + tool_activity_observed=True, + json_events=[], + agent_messages=[], + ) + assert not _unauthorized_cause(raw) + assert not result_has_pre_provider_refusal(raw) + + +@pytest.mark.parametrize( + "terminal,requests", + [ + ("HTTP 503", 1), + ("HTTP 429", 1), + ("idle timeout waiting for SSE", 1), + ("HTTP 401 Unauthorized", 2), + ], +) +def test_relay_owner_replays_only_current_401(monkeypatch, tmp_path, terminal, requests): + from argus_skill.provider_integrations.authorization_retry import AuthorizationRetryOwner + + owner = AuthorizationRetryOwner() + calls = [] + credential = tmp_path / "dummy-env" + credential.write_text("FIXTURE_TOKEN=placeholder\n") + monkeypatch.setattr( + owner, + "_credential_snapshot", + lambda *args: SimpleNamespace( + key=("fixture",), path=credential, env_key="FIXTURE_TOKEN", rejected="placeholder" + ), + ) + + def run(*args, **kwargs): + calls.append(True) + return AgentRunResult( + command=["fixture"], + exit_code=1, + turn_failed=True, + fatal_error=terminal if len(calls) == 1 else "HTTP 503", + stderr_lines=["HTTP 401 Unauthorized (recovered earlier)"], + ) + + monkeypatch.setattr("argus_skill.core.run_gateway.run_exec", run) + result = owner.run_agent_cli( + SimpleNamespace(_runner=object()), + prompt="fixture", + resume_thread_id=None, + options=object(), + run_label="fixture", + ) + assert len(calls) == requests + assert result.fatal_error == (terminal if requests == 1 else "HTTP 503") + + +def test_progress_survives_bounded_event_capture(): + raw = AgentRunResult( + command=["fixture"], + exit_code=1, + turn_failed=True, + fatal_error="Process exited with code 1 before turn completion.", + model_progress_observed=True, + json_events=[], + stderr_lines=["HTTP 401 recovered"], + ) + assert not _unauthorized_cause(raw) + + +def test_new_provider_error_replaces_recoverable_error_and_progress_clears_it(): + from argus_skill.agent_cli.agent_cli_runner import AgentCliRunner + + runner = AgentCliRunner(agent_bin="codex", backend="codex") + error = "HTTP 401 Unauthorized" + for event, expected in [ + ({"type": "error", "message": "HTTP 503"}, "HTTP 503"), + ({"type": "item.completed", "item": {"type": "command_execution"}}, None), + ]: + _, _, _, error = runner._consume_codex_event( + event=event, + thread_id=None, + agent_messages=[], + turn_completed=False, + turn_failed=False, + fatal_error=error, + ) + assert error == expected + + +def test_empty_terminal_error_replaces_recovered_401(): + from argus_skill.agent_cli.agent_cli_runner import AgentCliRunner + + runner = AgentCliRunner(agent_bin="codex", backend="codex") + _, _, failed, error = runner._consume_codex_event( + event={"type": "turn.failed", "error": {}}, thread_id=None, + agent_messages=[], turn_completed=False, turn_failed=False, + fatal_error="HTTP 401 recovered", + ) + assert failed + assert error == "Backend reported a failed turn." diff --git a/tests/test_terminal_stop_receipts.py b/tests/test_terminal_stop_receipts.py new file mode 100644 index 000000000..30476ec4e --- /dev/null +++ b/tests/test_terminal_stop_receipts.py @@ -0,0 +1,74 @@ +"""Continuation context and authoritative operator-stop regression coverage.""" +from types import SimpleNamespace + +import pytest + +from argus_skill.adapters.agent_cli_backend._result import UsageAccumulator, translate_result +from argus_skill.agent_cli._run_exec import _StreamState +from argus_skill.agent_cli.agent_cli_runner import AgentCliRunner, RunnerOptions +from argus_skill.provider_integrations.authorization_retry import _unauthorized_cause + + +@pytest.mark.parametrize("reason,kind", [ + ("External interrupt: daemon stop requested", "daemon_shutdown"), + ("External interrupt: operator pause requested", "operator_pause"), + ("External interrupt: operator abort requested", "operator_abort"), +]) +@pytest.mark.parametrize("old_error", [ + "Reconnecting... 1/5 (stream disconnected: idle timeout waiting for SSE)", + "HTTP 401 Unauthorized (recovered earlier)", +]) +def test_actual_stop_overrides_prior_provider_diagnostic(reason, kind, old_error): + runner = AgentCliRunner(agent_bin="codex", backend="codex") + state = _StreamState(thread_id="retained-session", fatal_error=old_error, + watchdog_terminated=True, watchdog_reason=reason) + state.stderr_lines.append(old_error) + raw = runner._finalize_turn_result(process=SimpleNamespace(returncode=1), + command=["codex"], options=RunnerOptions(), state=state) + assert raw.fatal_error == reason + result = translate_result(raw, resume_thread_id=None, copilot_usage=None, + usage_accumulator=UsageAccumulator()) + assert result.stop_kind == kind + assert result.fatal_error == reason + assert result.stderr_lines == [old_error] + assert result.thread_id == "retained-session" + assert _unauthorized_cause(raw) == "" + + +def test_catalog_warning_cannot_replace_explicit_operator_stop(): + runner = AgentCliRunner(agent_bin="codex", backend="codex") + reason = "External interrupt: daemon stop requested" + state = _StreamState(thread_id="retained", watchdog_terminated=True, watchdog_reason=reason) + state.stderr_lines.append("error: failed to load models: HTTP 401 Unauthorized") + raw = runner._finalize_turn_result(process=SimpleNamespace(returncode=1), + command=["codex"], options=RunnerOptions(), state=state) + result = translate_result(raw, resume_thread_id=None, copilot_usage=None, + usage_accumulator=UsageAccumulator()) + assert result.fatal_error == reason + assert result.stop_kind == "daemon_shutdown" + assert _unauthorized_cause(raw) == "" + + +def test_actual_provider_failure_without_stop_is_preserved(): + runner = AgentCliRunner(agent_bin="codex", backend="codex") + state = _StreamState(thread_id="retained", turn_failed=True, fatal_error="HTTP 401 Unauthorized") + raw = runner._finalize_turn_result(process=SimpleNamespace(returncode=1), + command=["codex"], options=RunnerOptions(), state=state) + result = translate_result(raw, resume_thread_id=None, copilot_usage=None, + usage_accumulator=UsageAccumulator()) + assert result.stop_kind == "permanent_error" + assert _unauthorized_cause(raw) == "HTTP 401 Unauthorized" + + +def test_backend_stop_does_not_set_auth_failure_flag(monkeypatch, tmp_path): + from argus_skill.adapters.agent_cli_backend import AgentCliBackend + from argus_skill.agent_cli.models import AgentRunResult + from argus_skill.core.models import RunnerOptions as BackendOptions + backend=AgentCliBackend(backend="codex") + raw=AgentRunResult(command=["codex"], exit_code=1, turn_failed=True, + fatal_error="External interrupt: daemon stop requested", + stderr_lines=["HTTP 401 Unauthorized (recovered earlier)"]) + monkeypatch.setattr(backend._runner, "run_exec", lambda **kwargs: raw) + result=backend.run_exec(prompt="synthetic fixture", options=BackendOptions(working_dir=str(tmp_path)), run_label="engineer-r1") + assert result.stop_kind == "daemon_shutdown" + assert not backend._auth_failure_detected