Skip to content

Commit aa38139

Browse files
Trecekclaude
andauthored
fix: stale RUNNING dispatch recovery with session-type-agnostic liveness (#4136)
## Summary Interactive sessions have zero path to recover stale RUNNING dispatch state. The bug is a convergence of two gaps: (1) `reset_dispatch` blocks on RUNNING status without checking process liveness, and (2) neither `open_kitchen` nor any interactive boot path runs the dispatch reaper. The architectural weakness is that liveness verification before status-based blocking is enforced by convention, not by structure — three callers do it correctly (`resume_campaign_from_state`, `_try_claim_with_liveness`, `reap_stale_dispatches`) while `reset_dispatch` does not. The immunity plan extracts a shared `resolve_stale_running` helper that centralizes the "RUNNING → check liveness → classify if dead" pattern, adds the reaper to `open_kitchen`, extends the existing AST boot-step symmetry guard to cover `open_kitchen`, and adds integration tests that would have caught this bug immediately. The remediation plan replaces mocked `resolve_stale_running` in two tests (`test_reset_dispatch_running_dead_process_succeeds` and `test_reset_dispatch_running_alive_process_still_blocked`) with real PID/boot-id fields on the dispatch record, exercising the full liveness detection path: `reset_dispatch` → `find_locked_dispatch` → `resolve_stale_running` → `is_dispatch_session_alive` → `is_session_alive`. Closes #4133 ## Implementation Plan Plan files: - `/home/talon/projects/autoskillit-runs/remediation-20260627-224639-242652/.autoskillit/temp/rectify/rectify_stale_running_dispatch_recovery_2026-06-27_224639.md` - `/home/talon/projects/autoskillit-runs/remediation-20260627-224639-242652/.autoskillit/temp/make-plan/remediation_stale_running_dispatch_recovery_plan_2026-06-28_001600.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code) via AutoSkillit <!-- autoskillit:pipeline-signature steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr --> ## Token Usage Summary | Step | Model | count | uncached | output | cache_read | peak_ctx | turns | cache_write | time | |------|-------|-------|----------|--------|------------|----------|-------|-------------|------| | plan* | opus[1m] | 3 | 9.1k | 37.2k | 4.4M | 123.0k | 132 | 231.7k | 45m 36s | | verify* | sonnet | 3 | 228 | 24.3k | 1.3M | 71.9k | 70 | 123.2k | 13m 37s | | implement* | MiniMax-M3 | 3 | 185.8k | 16.1k | 2.5M | 60.5k | 140 | 0 | 7m 54s | | retry_worktree* | sonnet | 1 | 76 | 3.6k | 314.5k | 40.7k | 24 | 22.2k | 2m 2s | | audit_impl* | sonnet | 3 | 756 | 16.1k | 723.1k | 42.9k | 46 | 79.7k | 10m 33s | | dispatch:1133b9e6-5319-48e2-b878-5f30f979fbcb* | sonnet | 1 | 170 | 5.8k | 1.4M | 77.7k | 51 | 77.9k | 20m 52s | | prepare_pr* | MiniMax-M3 | 2 | 85.9k | 4.6k | 386.8k | 0 | 29 | 0 | 1m 45s | | compose_pr* | MiniMax-M3 | 2 | 73.1k | 4.5k | 430.1k | 38.5k | 25 | 0 | 1m 45s | | review_pr* | sonnet | 4 | 482 | 46.6k | 2.7M | 59.5k | 155 | 161.1k | 13m 55s | | dispatch:9f9aa203-f3ea-44fd-9d3d-8f7ce0265f6f* | sonnet | 1 | 298 | 10.5k | 2.8M | 84.3k | 77 | 59.8k | 1h 5m | | merge_gate_fix* | sonnet | 1 | 110 | 5.5k | 527.3k | 47.6k | 35 | 29.5k | 2m 55s | | **Total** | | | 356.0k | 174.8k | 17.4M | 123.0k | | 785.2k | 3h 6m | \* *Step used a non-Anthropic provider; caching behavior may differ.* ## Token Efficiency | Step | LoC Changed | cache_read/LoC | cache_write/LoC | output/LoC | |------|-------------|----------------|-----------------|------------| | plan | 0 | — | — | — | | verify | 0 | — | — | — | | implement | 16 | 156056.6 | 0.0 | 1008.7 | | retry_worktree | 0 | — | — | — | | audit_impl | 0 | — | — | — | | dispatch:1133b9e6-5319-48e2-b878-5f30f979fbcb | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | review_pr | 0 | — | — | — | | dispatch:9f9aa203-f3ea-44fd-9d3d-8f7ce0265f6f | 0 | — | — | — | | merge_gate_fix | 7 | 75335.4 | 4210.6 | 791.0 | | **Total** | **23** | 755144.3 | 34140.5 | 7600.8 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | opus[1m] | 1 | 9.1k | 37.2k | 4.4M | 231.7k | 45m 36s | | sonnet | 7 | 2.1k | 112.4k | 9.7M | 553.5k | 2h 9m | | MiniMax-M3 | 3 | 344.7k | 25.3k | 3.3M | 0 | 11m 25s | --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3d7a765 commit aa38139

14 files changed

Lines changed: 357 additions & 32 deletions

src/autoskillit/fleet/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from ._reset import (
2525
ResetReport,
2626
find_dispatch_in_campaigns,
27+
find_locked_dispatch,
2728
format_resettable_statuses,
2829
reset_dispatch_artifacts,
2930
resolve_worktrees_dir,
@@ -81,6 +82,7 @@
8182
find_dispatch_for_issue,
8283
has_blocking_dispatch,
8384
has_completed_dispatch,
85+
resolve_stale_running,
8486
)
8587
from .state_types import (
8688
_INFRASTRUCTURE_FAILURE_REASONS, # noqa: F401
@@ -126,6 +128,7 @@
126128
"parse_campaign_summary",
127129
"serialize_campaign_summary",
128130
"validate_campaign_summary",
131+
"resolve_stale_running",
129132
"TERMINAL_DISPATCH_STATUSES",
130133
"TERMINAL_UNCLEANED_STATUSES",
131134
"FLEET_HALTED_SENTINEL",
@@ -179,6 +182,7 @@
179182
"ResetReport",
180183
"compute_reset_labels",
181184
"find_dispatch_in_campaigns",
185+
"find_locked_dispatch",
182186
"format_resettable_statuses",
183187
"reset_dispatch_artifacts",
184188
"resolve_worktrees_dir",

src/autoskillit/fleet/_dispatch_reaper.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,9 @@ def _apply_stale_dispatch(
4444
m: CampaignStateMutator,
4545
reaper_dispatch_id: str = "",
4646
) -> None:
47-
from autoskillit.fleet import classify_stale_dispatch # noqa: PLC0415
47+
from autoskillit.fleet import resolve_stale_running # noqa: PLC0415
4848

49-
new_status, sidecar = classify_stale_dispatch(dispatch)
50-
dispatch.status = new_status
51-
dispatch.reason = reason
52-
if sidecar:
53-
dispatch.sidecar_path = sidecar
49+
resolve_stale_running(dispatch, m, reason=reason)
5450
dispatch.ended_at = time.time()
5551

5652
dispatch.reaper_reason = reason
@@ -74,6 +70,7 @@ def _apply_stale_dispatch(
7470
logger.warning("reaper: failed to write tombstone", exc_info=True)
7571

7672
_append_reaper_event(dispatch, reason, reaper_dispatch_id)
73+
7774
m.mark_dirty()
7875

7976

src/autoskillit/fleet/_reset.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
__all__ = [
3636
"ResetReport",
3737
"find_dispatch_in_campaigns",
38+
"find_locked_dispatch",
3839
"compute_reset_labels",
3940
"format_resettable_statuses",
4041
"reset_dispatch_artifacts",
@@ -80,6 +81,16 @@ def find_dispatch_in_campaigns(
8081
return None
8182

8283

84+
def find_locked_dispatch(dispatch_id: str, mutator: CampaignStateMutator) -> DispatchRecord | None:
85+
"""Locate a dispatch inside a locked mutator's state by dispatch_id or name."""
86+
if mutator.state is None:
87+
return None
88+
for d in mutator.state.dispatches:
89+
if (d.dispatch_id and d.dispatch_id == dispatch_id) or d.name == dispatch_id:
90+
return d
91+
return None
92+
93+
8394
def compute_reset_labels(target_state: IssueLabelState) -> tuple[list[str], list[str]]:
8495
label_def = LABEL_LIFECYCLE_REGISTRY[target_state]
8596
remove = sorted(s.value for s in label_def.removes_on_entry | {IssueLabelState.IN_PROGRESS})

src/autoskillit/fleet/state_recovery.py

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import time
66
from pathlib import Path
7-
from typing import Any
7+
from typing import TYPE_CHECKING, Any
88

99
from autoskillit.core import (
1010
FleetErrorCode,
@@ -28,6 +28,9 @@
2828

2929
MAX_CONSECUTIVE_RESUME_ATTEMPTS = 3
3030

31+
if TYPE_CHECKING:
32+
from autoskillit.fleet.state import CampaignStateMutator
33+
3134
__all__ = [
3235
"classify_stale_dispatch",
3336
"derive_orchestrator_resume_spec",
@@ -36,6 +39,7 @@
3639
"has_blocking_dispatch",
3740
"has_completed_dispatch",
3841
"has_failed_dispatch",
42+
"resolve_stale_running",
3943
"resume_campaign_from_state",
4044
]
4145

@@ -209,6 +213,38 @@ def classify_stale_dispatch(
209213
return (DispatchStatus.INTERRUPTED, "")
210214

211215

216+
def resolve_stale_running(
217+
dispatch: DispatchRecord,
218+
mutator: CampaignStateMutator,
219+
*,
220+
reason: str = "stale_running_resolved",
221+
) -> bool:
222+
"""Check whether a RUNNING dispatch is actually alive.
223+
224+
Returns True if the dispatch is confirmed alive (caller should block).
225+
Returns False if the dispatch is dead — reclassifies it in-place within
226+
the mutator (to INTERRUPTED or RESUMABLE via classify_stale_dispatch)
227+
and marks the mutator dirty. Caller can proceed with the demoted status.
228+
229+
Precondition: dispatch.status == DispatchStatus.RUNNING.
230+
Precondition: dispatch is an element of mutator.state.dispatches
231+
(not a copy from an unlocked read).
232+
Precondition: called within a CampaignStateMutator context.
233+
"""
234+
from autoskillit.fleet import is_dispatch_session_alive
235+
236+
if is_dispatch_session_alive(dispatch):
237+
return True
238+
239+
new_status, sidecar_path = classify_stale_dispatch(dispatch)
240+
dispatch.status = new_status
241+
dispatch.reason = reason
242+
if sidecar_path:
243+
dispatch.sidecar_path = sidecar_path
244+
mutator.mark_dirty()
245+
return False
246+
247+
212248
def resume_campaign_from_state(
213249
state_path: Path,
214250
continue_on_failure: bool,
@@ -230,10 +266,9 @@ def resume_campaign_from_state(
230266
ResumeDecision with next_dispatch_name="" if all dispatches are
231267
complete or the campaign is halted.
232268
"""
233-
from autoskillit.fleet import is_dispatch_session_alive # noqa: PLC0415
234-
from autoskillit.fleet.state import (
235-
CampaignStateMutator, # noqa: PLC0415
236-
_clear_dispatch_for_retry, # noqa: PLC0415
269+
from autoskillit.fleet.state import ( # noqa: PLC0415
270+
CampaignStateMutator,
271+
_clear_dispatch_for_retry,
237272
)
238273

239274
with CampaignStateMutator(state_path) as m:
@@ -242,14 +277,7 @@ def resume_campaign_from_state(
242277

243278
for d in m.state.dispatches:
244279
if d.status == DispatchStatus.RUNNING:
245-
if is_dispatch_session_alive(d):
246-
continue
247-
new_status, sidecar_path = classify_stale_dispatch(d)
248-
d.status = new_status
249-
d.reason = "stale_running_on_resume"
250-
if sidecar_path:
251-
d.sidecar_path = sidecar_path
252-
m.mark_dirty()
280+
resolve_stale_running(d, m, reason="stale_running_on_resume")
253281

254282
if continue_on_failure and reset_on_retry:
255283
for d in m.state.dispatches:

src/autoskillit/server/tools/tools_fleet_reset.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@
1313
from autoskillit.core import FleetErrorCode, IssueLabelState, fleet_error, get_logger
1414
from autoskillit.fleet import (
1515
_RESETTABLE_STATUSES,
16+
CampaignStateMutator,
1617
DispatchStatus,
1718
ResetReport,
1819
cleanup_orphaned_labels,
1920
compute_reset_labels,
2021
discover_campaign_state_files,
2122
find_dispatch_in_campaigns,
23+
find_locked_dispatch,
2224
format_resettable_statuses,
2325
reset_dispatch_artifacts,
26+
resolve_stale_running,
2427
resolve_worktrees_dir,
2528
update_campaign_state,
2629
)
@@ -123,11 +126,24 @@ async def reset_dispatch(
123126
dispatch, state_path = result
124127

125128
if dispatch.status == DispatchStatus.RUNNING:
126-
return fleet_error(
127-
FleetErrorCode.FLEET_RESET_STILL_RUNNING,
128-
f"Dispatch {dispatch_id!r} is still RUNNING. "
129-
"Wait for it to finish or reap it first.",
130-
)
129+
with CampaignStateMutator(state_path) as m:
130+
locked_dispatch = find_locked_dispatch(dispatch_id, m)
131+
if locked_dispatch is None:
132+
return fleet_error(
133+
FleetErrorCode.FLEET_RESET_NOT_FOUND,
134+
f"No dispatch found matching {dispatch_id!r} in any campaign state file.",
135+
)
136+
if locked_dispatch.status != DispatchStatus.RUNNING:
137+
dispatch = locked_dispatch
138+
elif resolve_stale_running(locked_dispatch, m):
139+
return fleet_error(
140+
FleetErrorCode.FLEET_RESET_STILL_RUNNING,
141+
f"Dispatch {dispatch_id!r} is still RUNNING "
142+
f"(process {locked_dispatch.dispatched_pid} is alive). "
143+
"Wait for it to finish.",
144+
)
145+
else:
146+
dispatch = locked_dispatch
131147

132148
if dispatch.status not in _RESETTABLE_STATUSES:
133149
return fleet_error(

src/autoskillit/server/tools/tools_kitchen.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@
3939
resolve_kitchen_id,
4040
unregister_active_kitchen,
4141
)
42-
from autoskillit.fleet import FleetSemaphore
42+
from autoskillit.fleet import (
43+
FleetSemaphore,
44+
discover_campaign_state_files,
45+
reap_stale_dispatches_async,
46+
)
4347
from autoskillit.pipeline import create_background_task
4448
from autoskillit.server import mcp
4549
from autoskillit.server._guards import _backend_supports_quota, _require_orchestrator_exact
@@ -306,6 +310,17 @@ async def _open_kitchen_handler() -> str | None:
306310
except Exception:
307311
logger.warning("open_kitchen_registry_failed", exc_info=True)
308312

313+
try:
314+
_campaign_state_paths = discover_campaign_state_files(ctx.project_dir)
315+
if _campaign_state_paths:
316+
await reap_stale_dispatches_async(
317+
_campaign_state_paths,
318+
min_reap_age_seconds=60.0,
319+
heartbeat_grace_seconds=90.0,
320+
)
321+
except Exception:
322+
logger.warning("open_kitchen_reap_failed", exc_info=True)
323+
309324
ctx.gate_infrastructure_ready = True
310325
return None
311326

tests/arch/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ AST enforcement, sub-package layer contracts, and architectural invariant tests.
3636
| `test_judge_eval_skill.py` | Structural integrity tests for the judge-eval skill |
3737
| `test_agent_prompt_structure.py` | Structural validation: agent definition MUST-gate fallback, quote-verification gates, inconclusive output format, and empty-array documentation |
3838
| `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 |
39+
| `test_running_status_liveness_guard.py` | AST guard: DispatchStatus.RUNNING checks in server/tools/ must be guarded by liveness verification |
3940
| `test_bundled_recipes_split.py` | Enforcement: test_bundled_recipes.py split structure guard |
4041
| `test_cascade_map_guard.py` | REQ-GUARD-001..003, 005: CI guard validating cascade maps against AST-derived reverse import graph |
4142
| `test_channel_b_timeout_guard.py` | AST guard: Channel B tests must use timeout >= TimeoutTier.CHANNEL_B |

tests/arch/test_boot_step_symmetry.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
pytestmark = [pytest.mark.layer("arch"), pytest.mark.small]
1111

1212
LIFESPAN_PATH = Path(__file__).parents[2] / "src" / "autoskillit" / "server" / "_lifespan.py"
13+
KITCHEN_PATH = (
14+
Path(__file__).parents[2] / "src" / "autoskillit" / "server" / "tools" / "tools_kitchen.py"
15+
)
1316

1417
_REQUIRED_BOOT_STEPS: list[tuple[str, tuple[str, ...]]] = [
1518
("sweep_stale_dispatch_labels", ("_fleet_auto_gate_boot", "_food_truck_auto_gate_boot")),
@@ -99,3 +102,18 @@ def test_boot_step_ordering(
99102
f"{node.name}: {before_symbol} (line {before_lineno}) must appear "
100103
f"before {after_symbol} (line {after_lineno})"
101104
)
105+
106+
def test_open_kitchen_handler_calls_reaper(self) -> None:
107+
"""AST guard: _open_kitchen_handler must call reap_stale_dispatches_async.
108+
109+
Interactive sessions need the reaper at kitchen-open time since they
110+
never go through _fleet_auto_gate_boot or _food_truck_auto_gate_boot.
111+
"""
112+
assert KITCHEN_PATH.exists(), f"Production file not found: {KITCHEN_PATH}"
113+
tree = ast.parse(KITCHEN_PATH.read_text())
114+
assert _function_body_contains_symbol(
115+
tree, "_open_kitchen_handler", "reap_stale_dispatches_async"
116+
), (
117+
"_open_kitchen_handler must call reap_stale_dispatches_async "
118+
"to provide dispatch recovery for interactive sessions"
119+
)

0 commit comments

Comments
 (0)