diff --git a/src/autoskillit/fleet/__init__.py b/src/autoskillit/fleet/__init__.py index 6f741b8f9..0b541826f 100644 --- a/src/autoskillit/fleet/__init__.py +++ b/src/autoskillit/fleet/__init__.py @@ -24,6 +24,7 @@ from ._reset import ( ResetReport, find_dispatch_in_campaigns, + find_locked_dispatch, format_resettable_statuses, reset_dispatch_artifacts, resolve_worktrees_dir, @@ -81,6 +82,7 @@ find_dispatch_for_issue, has_blocking_dispatch, has_completed_dispatch, + resolve_stale_running, ) from .state_types import ( _INFRASTRUCTURE_FAILURE_REASONS, # noqa: F401 @@ -126,6 +128,7 @@ "parse_campaign_summary", "serialize_campaign_summary", "validate_campaign_summary", + "resolve_stale_running", "TERMINAL_DISPATCH_STATUSES", "TERMINAL_UNCLEANED_STATUSES", "FLEET_HALTED_SENTINEL", @@ -179,6 +182,7 @@ "ResetReport", "compute_reset_labels", "find_dispatch_in_campaigns", + "find_locked_dispatch", "format_resettable_statuses", "reset_dispatch_artifacts", "resolve_worktrees_dir", diff --git a/src/autoskillit/fleet/_dispatch_reaper.py b/src/autoskillit/fleet/_dispatch_reaper.py index 36f045d9e..8d2d7f2ed 100644 --- a/src/autoskillit/fleet/_dispatch_reaper.py +++ b/src/autoskillit/fleet/_dispatch_reaper.py @@ -44,13 +44,9 @@ def _apply_stale_dispatch( m: CampaignStateMutator, reaper_dispatch_id: str = "", ) -> None: - from autoskillit.fleet import classify_stale_dispatch # noqa: PLC0415 + from autoskillit.fleet import resolve_stale_running # noqa: PLC0415 - new_status, sidecar = classify_stale_dispatch(dispatch) - dispatch.status = new_status - dispatch.reason = reason - if sidecar: - dispatch.sidecar_path = sidecar + resolve_stale_running(dispatch, m, reason=reason) dispatch.ended_at = time.time() dispatch.reaper_reason = reason @@ -74,6 +70,7 @@ def _apply_stale_dispatch( logger.warning("reaper: failed to write tombstone", exc_info=True) _append_reaper_event(dispatch, reason, reaper_dispatch_id) + m.mark_dirty() diff --git a/src/autoskillit/fleet/_reset.py b/src/autoskillit/fleet/_reset.py index 70325d199..a1597269f 100644 --- a/src/autoskillit/fleet/_reset.py +++ b/src/autoskillit/fleet/_reset.py @@ -35,6 +35,7 @@ __all__ = [ "ResetReport", "find_dispatch_in_campaigns", + "find_locked_dispatch", "compute_reset_labels", "format_resettable_statuses", "reset_dispatch_artifacts", @@ -80,6 +81,16 @@ def find_dispatch_in_campaigns( return None +def find_locked_dispatch(dispatch_id: str, mutator: CampaignStateMutator) -> DispatchRecord | None: + """Locate a dispatch inside a locked mutator's state by dispatch_id or name.""" + if mutator.state is None: + return None + for d in mutator.state.dispatches: + if (d.dispatch_id and d.dispatch_id == dispatch_id) or d.name == dispatch_id: + return d + return None + + def compute_reset_labels(target_state: IssueLabelState) -> tuple[list[str], list[str]]: label_def = LABEL_LIFECYCLE_REGISTRY[target_state] remove = sorted(s.value for s in label_def.removes_on_entry | {IssueLabelState.IN_PROGRESS}) diff --git a/src/autoskillit/fleet/state_recovery.py b/src/autoskillit/fleet/state_recovery.py index d255680b0..d987c2375 100644 --- a/src/autoskillit/fleet/state_recovery.py +++ b/src/autoskillit/fleet/state_recovery.py @@ -4,7 +4,7 @@ import time from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from autoskillit.core import ( FleetErrorCode, @@ -28,6 +28,9 @@ MAX_CONSECUTIVE_RESUME_ATTEMPTS = 3 +if TYPE_CHECKING: + from autoskillit.fleet.state import CampaignStateMutator + __all__ = [ "classify_stale_dispatch", "derive_orchestrator_resume_spec", @@ -36,6 +39,7 @@ "has_blocking_dispatch", "has_completed_dispatch", "has_failed_dispatch", + "resolve_stale_running", "resume_campaign_from_state", ] @@ -209,6 +213,38 @@ def classify_stale_dispatch( return (DispatchStatus.INTERRUPTED, "") +def resolve_stale_running( + dispatch: DispatchRecord, + mutator: CampaignStateMutator, + *, + reason: str = "stale_running_resolved", +) -> bool: + """Check whether a RUNNING dispatch is actually alive. + + Returns True if the dispatch is confirmed alive (caller should block). + Returns False if the dispatch is dead — reclassifies it in-place within + the mutator (to INTERRUPTED or RESUMABLE via classify_stale_dispatch) + and marks the mutator dirty. Caller can proceed with the demoted status. + + Precondition: dispatch.status == DispatchStatus.RUNNING. + Precondition: dispatch is an element of mutator.state.dispatches + (not a copy from an unlocked read). + Precondition: called within a CampaignStateMutator context. + """ + from autoskillit.fleet import is_dispatch_session_alive + + if is_dispatch_session_alive(dispatch): + return True + + new_status, sidecar_path = classify_stale_dispatch(dispatch) + dispatch.status = new_status + dispatch.reason = reason + if sidecar_path: + dispatch.sidecar_path = sidecar_path + mutator.mark_dirty() + return False + + def resume_campaign_from_state( state_path: Path, continue_on_failure: bool, @@ -230,10 +266,9 @@ def resume_campaign_from_state( ResumeDecision with next_dispatch_name="" if all dispatches are complete or the campaign is halted. """ - from autoskillit.fleet import is_dispatch_session_alive # noqa: PLC0415 - from autoskillit.fleet.state import ( - CampaignStateMutator, # noqa: PLC0415 - _clear_dispatch_for_retry, # noqa: PLC0415 + from autoskillit.fleet.state import ( # noqa: PLC0415 + CampaignStateMutator, + _clear_dispatch_for_retry, ) with CampaignStateMutator(state_path) as m: @@ -242,14 +277,7 @@ def resume_campaign_from_state( for d in m.state.dispatches: if d.status == DispatchStatus.RUNNING: - if is_dispatch_session_alive(d): - continue - new_status, sidecar_path = classify_stale_dispatch(d) - d.status = new_status - d.reason = "stale_running_on_resume" - if sidecar_path: - d.sidecar_path = sidecar_path - m.mark_dirty() + resolve_stale_running(d, m, reason="stale_running_on_resume") if continue_on_failure and reset_on_retry: for d in m.state.dispatches: diff --git a/src/autoskillit/server/tools/tools_fleet_reset.py b/src/autoskillit/server/tools/tools_fleet_reset.py index f8282dda8..9ecd91e68 100644 --- a/src/autoskillit/server/tools/tools_fleet_reset.py +++ b/src/autoskillit/server/tools/tools_fleet_reset.py @@ -13,14 +13,17 @@ from autoskillit.core import FleetErrorCode, IssueLabelState, fleet_error, get_logger from autoskillit.fleet import ( _RESETTABLE_STATUSES, + CampaignStateMutator, DispatchStatus, ResetReport, cleanup_orphaned_labels, compute_reset_labels, discover_campaign_state_files, find_dispatch_in_campaigns, + find_locked_dispatch, format_resettable_statuses, reset_dispatch_artifacts, + resolve_stale_running, resolve_worktrees_dir, update_campaign_state, ) @@ -123,11 +126,24 @@ async def reset_dispatch( dispatch, state_path = result if dispatch.status == DispatchStatus.RUNNING: - return fleet_error( - FleetErrorCode.FLEET_RESET_STILL_RUNNING, - f"Dispatch {dispatch_id!r} is still RUNNING. " - "Wait for it to finish or reap it first.", - ) + with CampaignStateMutator(state_path) as m: + locked_dispatch = find_locked_dispatch(dispatch_id, m) + if locked_dispatch is None: + return fleet_error( + FleetErrorCode.FLEET_RESET_NOT_FOUND, + f"No dispatch found matching {dispatch_id!r} in any campaign state file.", + ) + if locked_dispatch.status != DispatchStatus.RUNNING: + dispatch = locked_dispatch + elif resolve_stale_running(locked_dispatch, m): + return fleet_error( + FleetErrorCode.FLEET_RESET_STILL_RUNNING, + f"Dispatch {dispatch_id!r} is still RUNNING " + f"(process {locked_dispatch.dispatched_pid} is alive). " + "Wait for it to finish.", + ) + else: + dispatch = locked_dispatch if dispatch.status not in _RESETTABLE_STATUSES: return fleet_error( diff --git a/src/autoskillit/server/tools/tools_kitchen.py b/src/autoskillit/server/tools/tools_kitchen.py index 201a1131d..6e4aeaf64 100644 --- a/src/autoskillit/server/tools/tools_kitchen.py +++ b/src/autoskillit/server/tools/tools_kitchen.py @@ -39,7 +39,11 @@ resolve_kitchen_id, unregister_active_kitchen, ) -from autoskillit.fleet import FleetSemaphore +from autoskillit.fleet import ( + FleetSemaphore, + discover_campaign_state_files, + reap_stale_dispatches_async, +) from autoskillit.pipeline import create_background_task from autoskillit.server import mcp from autoskillit.server._guards import _backend_supports_quota, _require_orchestrator_exact @@ -306,6 +310,17 @@ async def _open_kitchen_handler() -> str | None: except Exception: logger.warning("open_kitchen_registry_failed", exc_info=True) + try: + _campaign_state_paths = discover_campaign_state_files(ctx.project_dir) + if _campaign_state_paths: + await reap_stale_dispatches_async( + _campaign_state_paths, + min_reap_age_seconds=60.0, + heartbeat_grace_seconds=90.0, + ) + except Exception: + logger.warning("open_kitchen_reap_failed", exc_info=True) + ctx.gate_infrastructure_ready = True return None diff --git a/tests/arch/AGENTS.md b/tests/arch/AGENTS.md index 6507a8699..befaf8e42 100644 --- a/tests/arch/AGENTS.md +++ b/tests/arch/AGENTS.md @@ -36,6 +36,7 @@ AST enforcement, sub-package layer contracts, and architectural invariant tests. | `test_judge_eval_skill.py` | Structural integrity tests for the judge-eval skill | | `test_agent_prompt_structure.py` | Structural validation: agent definition MUST-gate fallback, quote-verification gates, inconclusive output format, and empty-array documentation | | `test_boot_step_symmetry.py` | AST guard: both boot functions (_fleet_auto_gate_boot, _food_truck_auto_gate_boot) must call sweep_stale_dispatch_labels | +| `test_running_status_liveness_guard.py` | AST guard: DispatchStatus.RUNNING checks in server/tools/ must be guarded by liveness verification | | `test_bundled_recipes_split.py` | Enforcement: test_bundled_recipes.py split structure guard | | `test_cascade_map_guard.py` | REQ-GUARD-001..003, 005: CI guard validating cascade maps against AST-derived reverse import graph | | `test_channel_b_timeout_guard.py` | AST guard: Channel B tests must use timeout >= TimeoutTier.CHANNEL_B | diff --git a/tests/arch/test_boot_step_symmetry.py b/tests/arch/test_boot_step_symmetry.py index 5b5404caa..b4ad2601f 100644 --- a/tests/arch/test_boot_step_symmetry.py +++ b/tests/arch/test_boot_step_symmetry.py @@ -10,6 +10,9 @@ pytestmark = [pytest.mark.layer("arch"), pytest.mark.small] LIFESPAN_PATH = Path(__file__).parents[2] / "src" / "autoskillit" / "server" / "_lifespan.py" +KITCHEN_PATH = ( + Path(__file__).parents[2] / "src" / "autoskillit" / "server" / "tools" / "tools_kitchen.py" +) _REQUIRED_BOOT_STEPS: list[tuple[str, tuple[str, ...]]] = [ ("sweep_stale_dispatch_labels", ("_fleet_auto_gate_boot", "_food_truck_auto_gate_boot")), @@ -99,3 +102,18 @@ def test_boot_step_ordering( f"{node.name}: {before_symbol} (line {before_lineno}) must appear " f"before {after_symbol} (line {after_lineno})" ) + + def test_open_kitchen_handler_calls_reaper(self) -> None: + """AST guard: _open_kitchen_handler must call reap_stale_dispatches_async. + + Interactive sessions need the reaper at kitchen-open time since they + never go through _fleet_auto_gate_boot or _food_truck_auto_gate_boot. + """ + assert KITCHEN_PATH.exists(), f"Production file not found: {KITCHEN_PATH}" + tree = ast.parse(KITCHEN_PATH.read_text()) + assert _function_body_contains_symbol( + tree, "_open_kitchen_handler", "reap_stale_dispatches_async" + ), ( + "_open_kitchen_handler must call reap_stale_dispatches_async " + "to provide dispatch recovery for interactive sessions" + ) diff --git a/tests/arch/test_running_status_liveness_guard.py b/tests/arch/test_running_status_liveness_guard.py new file mode 100644 index 000000000..88118083a --- /dev/null +++ b/tests/arch/test_running_status_liveness_guard.py @@ -0,0 +1,108 @@ +"""Arch guard: any function in server/tools/ that checks DispatchStatus.RUNNING +must also verify liveness via is_dispatch_session_alive or resolve_stale_running. + +Without this guard, new MCP tools can silently encode the bug pattern that +caused issue #4133 — treating a status claim as a fact without verifying +the owning process is alive. +""" + +from __future__ import annotations + +import ast + +import pytest + +pytestmark = [pytest.mark.layer("arch"), pytest.mark.small] + +_LIVENESS_HELPERS: frozenset[str] = frozenset( + {"is_dispatch_session_alive", "resolve_stale_running"} +) + + +class _RunningStatusChecker(ast.NodeVisitor): + """Find DispatchStatus.RUNNING comparisons and verify liveness helper usage.""" + + def __init__(self, fn_node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + self.fn_node = fn_node + self.running_check_linenos: list[int] = [] + self.has_liveness_helper = False + + def visit_Compare(self, node: ast.Compare) -> None: + if self._is_running_status_check(node): + self.running_check_linenos.append(node.lineno) + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + if self._is_liveness_helper_call(node): + self.has_liveness_helper = True + self.generic_visit(node) + + def _is_running_status_check(self, node: ast.Compare) -> bool: + if not isinstance(node.left, ast.Attribute): + return False + if node.left.attr != "status": + return False + for comparator in node.comparators: + if not isinstance(comparator, ast.Attribute): + continue + if comparator.attr != "RUNNING": + continue + if not isinstance(node.ops[0], (ast.Eq, ast.Is)): + continue + return True + return False + + def _is_liveness_helper_call(self, node: ast.Call) -> bool: + if isinstance(node.func, ast.Name) and node.func.id in _LIVENESS_HELPERS: + return True + if isinstance(node.func, ast.Attribute) and node.func.attr in _LIVENESS_HELPERS: + return True + return False + + +def _get_function_defs(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunctionDef]: + fns: list[ast.FunctionDef | ast.AsyncFunctionDef] = [] + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + fns.append(node) + return fns + + +def test_running_status_checks_require_liveness(): + """Every DispatchStatus.RUNNING check in server/tools/ must be guarded + by a liveness verification (is_dispatch_session_alive or resolve_stale_running). + """ + from autoskillit.core import paths + + tools_dir = paths.pkg_root() / "server" / "tools" + assert tools_dir.is_dir(), f"server/tools/ directory not found at {tools_dir}" + + violations: list[str] = [] + for py_file in sorted(tools_dir.glob("*.py")): + try: + tree = ast.parse(py_file.read_text()) + except SyntaxError: + continue + + for fn in _get_function_defs(tree): + checker = _RunningStatusChecker(fn) + checker.visit(fn) + + if not checker.running_check_linenos: + continue + + if checker.has_liveness_helper: + continue + + relpath = str(py_file.relative_to(paths.pkg_root())) + for lineno in checker.running_check_linenos: + violations.append( + f"{relpath}:{lineno} — {fn.name}() checks " + "DispatchStatus.RUNNING without verifying liveness. " + f"Use {' or '.join(sorted(_LIVENESS_HELPERS))} " + "before treating RUNNING as a blocking fact." + ) + + assert not violations, "RUNNING status checks without liveness verification:\n" + "\n".join( + f" {v}" for v in violations + ) diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index ad529bd50..41b5d418d 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -967,7 +967,7 @@ def test_data_directories_are_not_python_packages() -> None: "co-located with the execution engine that calls them", ), "tools_kitchen.py": ( - 1280, + 1290, "REQ-CNST-010-E7: kitchen tool handlers — open_kitchen and lock_ingredients require " "inline validation helpers (_check_override_keys, _build_ingredient_key_suggestions) " "for ingredient key validation; splitting would cross import-layer boundaries; " @@ -980,7 +980,9 @@ def test_data_directories_are_not_python_packages() -> None: "_dispatch_infeasible_response helper for DOA pipeline refusal" "; capability admission control dispatch_feasible gating on deferred-recall and " "get_recipe paths (+22 net lines)" - "; supports_quota_check bool at call sites replaces resolve_provider (+3 net lines)", + "; supports_quota_check bool at call sites replaces resolve_provider (+3 net lines)" + "; reap_stale_dispatches_async call in _open_kitchen_handler for interactive session " + "dispatch recovery (+8 net lines)", ), "tools_execution.py": ( 1200, diff --git a/tests/infra/test_schema_version_convention.py b/tests/infra/test_schema_version_convention.py index 998e6ffb5..ff548013f 100644 --- a/tests/infra/test_schema_version_convention.py +++ b/tests/infra/test_schema_version_convention.py @@ -119,11 +119,11 @@ def _is_yaml_dump(node: ast.expr) -> bool: # _lifespan.py — hooks.json self-heal on startup drift (co-owned with Claude plugin system) ("src/autoskillit/server/_lifespan.py", 90), # tools_kitchen.py — hook config, quota guard, git_ops_policy, ingredient locks overlay - ("src/autoskillit/server/tools/tools_kitchen.py", 200), - ("src/autoskillit/server/tools/tools_kitchen.py", 219), - ("src/autoskillit/server/tools/tools_kitchen.py", 253), - ("src/autoskillit/server/tools/tools_kitchen.py", 998), - ("src/autoskillit/server/tools/tools_kitchen.py", 1058), + ("src/autoskillit/server/tools/tools_kitchen.py", 204), + ("src/autoskillit/server/tools/tools_kitchen.py", 223), + ("src/autoskillit/server/tools/tools_kitchen.py", 257), + ("src/autoskillit/server/tools/tools_kitchen.py", 1013), + ("src/autoskillit/server/tools/tools_kitchen.py", 1073), # tools_pipeline_tracker.py — tracker_data dict ("src/autoskillit/server/tools/tools_pipeline_tracker.py", 166), # tools_status.py — mcp_data dict diff --git a/tests/server/conftest.py b/tests/server/conftest.py index 5753db1a0..d74cf0497 100644 --- a/tests/server/conftest.py +++ b/tests/server/conftest.py @@ -152,6 +152,28 @@ async def _noop(*_args, **_kwargs): ) +@pytest.fixture(autouse=True) +def _patch_kitchen_reaper(monkeypatch): + """Neutralize reaper calls inside _open_kitchen_handler for unit tests. + + _open_kitchen_handler now calls discover_campaign_state_files and + reap_stale_dispatches_async. Existing kitchen handler tests rely on + filesystem absence to make these no-ops; this fixture makes that + guarantee explicit and stable regardless of host filesystem state. + """ + monkeypatch.setattr( + "autoskillit.server.tools.tools_kitchen.discover_campaign_state_files", + lambda _project_dir: [], + ) + + async def _noop_reaper(*_args, **_kwargs): + return None + + monkeypatch.setattr( + "autoskillit.server.tools.tools_kitchen.reap_stale_dispatches_async", _noop_reaper + ) + + @pytest.fixture def build_ctx(tmp_path): """Factory: build_ctx(**overrides) → minimal ToolContext with overrides applied.""" diff --git a/tests/server/test_kitchen_lifecycle.py b/tests/server/test_kitchen_lifecycle.py index dff3a6103..07b34850a 100644 --- a/tests/server/test_kitchen_lifecycle.py +++ b/tests/server/test_kitchen_lifecycle.py @@ -61,6 +61,48 @@ async def test_kitchen_open_close_lifecycle(monkeypatch, tmp_path): assert task.cancelled() or task.done() +async def test_open_kitchen_runs_reaper(monkeypatch, tmp_path): + """Test 1C: _open_kitchen_handler must call reap_stale_dispatches_async. + + Interactive sessions need the reaper at kitchen-open time since they + never go through _fleet_auto_gate_boot or _food_truck_auto_gate_boot. + """ + monkeypatch.chdir(tmp_path) + + ctx = make_context( + AutomationConfig(), + runner=None, + plugin_source=DirectInstall(plugin_dir=tmp_path), + project_dir=tmp_path, + ) + monkeypatch.setattr(_state, "_ctx", ctx) + monkeypatch.setattr(_state, "_startup_ready", None) + + fake_state_path = tmp_path / "dispatches" / "campaign.json" + + reaper_called = AsyncMock() + + monkeypatch.setattr( + "autoskillit.server.tools.tools_kitchen.discover_campaign_state_files", + lambda _project_dir: [fake_state_path], + ) + monkeypatch.setattr( + "autoskillit.server.tools.tools_kitchen.reap_stale_dispatches_async", + reaper_called, + ) + + with ( + patch("autoskillit.server.tools.tools_kitchen._prime_quota_cache", new_callable=AsyncMock), + patch("autoskillit.core.register_active_kitchen"), + ): + result = await _open_kitchen_handler() + assert result is None + reaper_called.assert_awaited_once() + call_args = reaper_called.await_args + assert call_args is not None + assert list(call_args.args[0]) == [fake_state_path] + + async def test_close_kitchen_removes_tracker_dir(monkeypatch, tmp_path): """Tracker directory is removed by close_kitchen handler.""" monkeypatch.chdir(tmp_path) diff --git a/tests/server/test_tools_fleet_reset.py b/tests/server/test_tools_fleet_reset.py index 1fc241630..5159d080c 100644 --- a/tests/server/test_tools_fleet_reset.py +++ b/tests/server/test_tools_fleet_reset.py @@ -3,12 +3,14 @@ from __future__ import annotations import json +import os from pathlib import Path from unittest.mock import AsyncMock, patch import pytest from autoskillit.core import TerminationReason +from autoskillit.core.runtime import read_boot_id, read_starttime_ticks from autoskillit.fleet import DispatchRecord, DispatchStatus, write_initial_state pytestmark = [pytest.mark.layer("server"), pytest.mark.medium, pytest.mark.feature("fleet")] @@ -245,6 +247,65 @@ async def test_reset_dispatch_running_rejected( tool_ctx = build_ctx_open() _setup_tool(tool_ctx, monkeypatch, state_path) + monkeypatch.setattr( + "autoskillit.server.tools.tools_fleet_reset.resolve_stale_running", + lambda _d, _m, **_kw: True, + ) + + from autoskillit.server.tools.tools_fleet_reset import reset_dispatch + + raw = await reset_dispatch(dispatch_id="d-abc123") + result = json.loads(raw) + assert result["success"] is False + assert result["error"] == "fleet_reset_still_running" + + @pytest.mark.anyio + async def test_reset_dispatch_running_dead_process_succeeds( + self, build_ctx_open, tmp_path, monkeypatch + ) -> None: + """Test 1A: reset_dispatch must succeed for RUNNING dispatch with dead process.""" + state_path = _setup_state(tmp_path, status=DispatchStatus.RUNNING) + from autoskillit.fleet.state import CampaignStateMutator + + with CampaignStateMutator(state_path) as m: + assert m.state is not None + m.state.dispatches[0].dispatched_pid = 999999999 + m.state.dispatches[0].dispatched_boot_id = "fake-boot-id" + m.mark_dirty() + + tool_ctx = build_ctx_open() + _setup_tool(tool_ctx, monkeypatch, state_path) + + from autoskillit.server.tools.tools_fleet_reset import reset_dispatch + + raw = await reset_dispatch(dispatch_id="d-abc123") + result = json.loads(raw) + assert result["success"] is True + + @pytest.mark.anyio + async def test_reset_dispatch_running_alive_process_still_blocked( + self, build_ctx_open, tmp_path, monkeypatch + ) -> None: + """Test 1B: reset_dispatch must still block when process is alive.""" + state_path = _setup_state(tmp_path, status=DispatchStatus.RUNNING) + from autoskillit.fleet.state import CampaignStateMutator + + current_pid = os.getpid() + current_boot_id = read_boot_id() + current_ticks = read_starttime_ticks(current_pid) + if current_boot_id is None or current_ticks is None: + pytest.skip("Liveness detection requires /proc (Linux only)") + + with CampaignStateMutator(state_path) as m: + assert m.state is not None + m.state.dispatches[0].dispatched_pid = current_pid + m.state.dispatches[0].dispatched_boot_id = current_boot_id + m.state.dispatches[0].dispatched_starttime_ticks = current_ticks + m.mark_dirty() + + tool_ctx = build_ctx_open() + _setup_tool(tool_ctx, monkeypatch, state_path) + from autoskillit.server.tools.tools_fleet_reset import reset_dispatch raw = await reset_dispatch(dispatch_id="d-abc123")