Skip to content

Commit 7f63bc3

Browse files
Trecekclaude
andauthored
Implementation Plan: Eliminate Silent Discard of stream_idle_timeout_ms in Codex Builders (#4076)
## Summary Add a `process_idle_timeout_ms: int = 0` field to the `CmdSpec` frozen dataclass, wire it through both Codex builder methods (`build_skill_session_cmd` and `build_food_truck_cmd`), and add an override path in `_headless_execute.py` so the existing idle watchdog respects the CmdSpec-carried value. This eliminates the silent `noqa: F841` discard of `stream_idle_timeout_ms` in the Codex backend, making stuck Codex sessions killable by the existing `_watch_stdout_idle` coroutine. ## Implementation Plan Plan file: `/home/talon/projects/autoskillit-runs/impl-20260611-142225-008330/.autoskillit/temp/make-plan/t5_p1_a6_wp1_eliminate_silent_discard_plan_2026-06-11_143500.md` Closes #4003 🤖 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] | 1 | 83 | 28.7k | 1.5M | 115.0k | 57 | 100.9k | 13m 19s | | verify* | sonnet | 1 | 62 | 18.6k | 324.1k | 74.1k | 21 | 53.2k | 8m 21s | | implement* | MiniMax-M3 | 1 | 2.2M | 11.7k | 0 | 0 | 67 | 0 | 6m 37s | | fix* | sonnet | 1 | 190 | 10.7k | 1.7M | 98.2k | 63 | 77.3k | 8m 12s | | audit_impl* | sonnet | 1 | 52 | 8.2k | 207.1k | 42.1k | 15 | 31.0k | 4m 58s | | prepare_pr* | MiniMax-M3 | 1 | 197.8k | 3.0k | 0 | 0 | 12 | 0 | 1m 2s | | compose_pr* | MiniMax-M3 | 1 | 251.0k | 1.8k | 0 | 0 | 15 | 0 | 49s | | **Total** | | | 2.7M | 82.5k | 3.8M | 115.0k | | 262.4k | 43m 21s | \* *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 | 217 | 0.0 | 0.0 | 53.9 | | fix | 12 | 145016.6 | 6439.9 | 890.7 | | audit_impl | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | **Total** | **229** | 16390.1 | 1145.7 | 360.5 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | opus[1m] | 1 | 83 | 28.7k | 1.5M | 100.9k | 13m 19s | | sonnet | 3 | 304 | 37.4k | 2.3M | 161.5k | 21m 32s | | MiniMax-M3 | 3 | 2.7M | 16.4k | 0 | 0 | 8m 29s | --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1c67de0 commit 7f63bc3

10 files changed

Lines changed: 220 additions & 9 deletions

File tree

src/autoskillit/core/types/_type_backend.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ class CmdSpec:
263263
cwd: str = ""
264264
origin: CmdOrigin | None = None
265265
is_resume: bool = False
266+
process_idle_timeout_ms: int = 0
266267

267268

268269
@dataclass(frozen=True, slots=True)

src/autoskillit/execution/backends/codex.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,7 @@ def build_skill_session_cmd(
626626
output_format = config.output_format # noqa: F841 # no-op: --json is unconditional for Codex
627627
add_dirs = config.add_dirs
628628
exit_after_stop_delay_ms = config.exit_after_stop_delay_ms # noqa: F841 # no-op: Claude-only
629-
stream_idle_timeout_ms = config.stream_idle_timeout_ms # noqa: F841 # no-op: Claude-only
629+
stream_idle_timeout_ms = config.stream_idle_timeout_ms
630630
scenario_step_name = config.scenario_step_name
631631
temp_dir_relpath = config.temp_dir_relpath
632632
allowed_write_prefix = config.allowed_write_prefix
@@ -724,7 +724,13 @@ def build_skill_session_cmd(
724724
cmd.append(resume_session_id)
725725
cmd.append(prompt)
726726

727-
return CmdSpec(cmd=tuple(cmd), env=env, cwd=cwd, is_resume=bool(resume_session_id))
727+
return CmdSpec(
728+
cmd=tuple(cmd),
729+
env=env,
730+
cwd=cwd,
731+
is_resume=bool(resume_session_id),
732+
process_idle_timeout_ms=stream_idle_timeout_ms,
733+
)
728734

729735
def build_food_truck_cmd(
730736
self,
@@ -750,7 +756,6 @@ def build_food_truck_cmd(
750756
_plugin_source = plugin_source # noqa: F841 # no-op: Codex has no --plugin-dir
751757
_output_format = output_format # noqa: F841 # no-op: --json is unconditional
752758
_exit_ms = exit_after_stop_delay_ms # noqa: F841 # no-op: Claude-only
753-
_stream_ms = stream_idle_timeout_ms # noqa: F841 # no-op: Claude-only
754759

755760
if resume_session_id:
756761
effective_prompt = _compose_resume_prompt(
@@ -823,7 +828,13 @@ def build_food_truck_cmd(
823828
cmd.append(resume_session_id)
824829
cmd.append(prompt)
825830

826-
return CmdSpec(cmd=tuple(cmd), env=env, cwd=cwd, is_resume=bool(resume_session_id))
831+
return CmdSpec(
832+
cmd=tuple(cmd),
833+
env=env,
834+
cwd=cwd,
835+
is_resume=bool(resume_session_id),
836+
process_idle_timeout_ms=stream_idle_timeout_ms,
837+
)
827838

828839
def build_interactive_cmd(
829840
self,

src/autoskillit/execution/headless/_headless_execute.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@ async def _execute_claude_headless(
140140
else:
141141
_raw_idle = float(cfg.idle_output_timeout)
142142
effective_idle: float | None = _raw_idle if _raw_idle > 0.0 else None
143+
if spec.process_idle_timeout_ms > 0:
144+
_spec_idle = spec.process_idle_timeout_ms / 1000.0
145+
if effective_idle is None or _spec_idle < effective_idle:
146+
effective_idle = _spec_idle
143147

144148
current_provider_name: str = provider_name
145149
fallback_activated: bool = False

tests/_test_filter.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,7 @@ class ImportContext(enum.StrEnum):
649649
"execution/test_headless_result_write_reconciliation.py",
650650
"execution/test_planner_write_isolation.py",
651651
"execution/test_session_log_flush.py",
652-
# execution/ — fixture-mediated pipeline dependents (15 files):
652+
# execution/ — fixture-mediated pipeline dependents (16 files):
653653
# These use minimal_ctx or tool_ctx fixtures which import
654654
# autoskillit.pipeline at call time. Validated by REQ-GUARD-007.
655655
"execution/test_backend_dispatch.py",
@@ -662,6 +662,7 @@ class ImportContext(enum.StrEnum):
662662
"execution/test_headless_dispatch.py",
663663
"execution/test_headless_env_injection.py",
664664
"execution/test_headless_env_scrub.py",
665+
"execution/test_headless_execute.py",
665666
"execution/test_headless_provider_fallback.py",
666667
"execution/test_headless_synthesis.py",
667668
"execution/test_idle_output_env.py",

tests/arch/test_cascade_map_guard.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,7 @@ def test_every_file_level_entry_references_existing_test(self) -> None:
680680
"execution/test_headless_dispatch.py",
681681
"execution/test_headless_env_injection.py",
682682
"execution/test_headless_env_scrub.py",
683+
"execution/test_headless_execute.py",
683684
"execution/test_headless_provider_fallback.py",
684685
"execution/test_headless_synthesis.py",
685686
"execution/test_idle_output_env.py",

tests/arch/test_execution_source_split.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
HEADLESS_SIZE_BUDGETS = {
1919
"headless/__init__.py": 490,
2020
"headless/_headless_helpers.py": 220,
21-
"headless/_headless_execute.py": 610,
21+
"headless/_headless_execute.py": 616,
2222
"headless/_headless_recovery.py": 370,
2323
"headless/_headless_path_tokens.py": 190,
2424
"headless/_headless_result.py": 865,

tests/arch/test_subpackage_isolation.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -979,7 +979,7 @@ def test_data_directories_are_not_python_packages() -> None:
979979
"dispatch gate add defense-in-depth checks",
980980
),
981981
"execution/backends/codex.py": (
982-
1045,
982+
1060,
983983
"REQ-CNST-010-E9: Codex backend — skill_sigil capability threading adds multi-line "
984984
"keyword args to _ensure_skill_prefix call sites and _has_prefix guard; "
985985
"write_guard_tool_names env injection adds 7 lines to _codex_exec_extras; "
@@ -989,7 +989,9 @@ def test_data_directories_are_not_python_packages() -> None:
989989
"promoted from locate_session parameter to frozen dataclass field (3 net lines: "
990990
"field declaration, blank line between field and method, SessionLocator in import block)"
991991
"; project_log_dir method added to CodexSessionLocator (+3 net lines)"
992-
"; session_log_path method added to CodexSessionLocator (+5 net lines)",
992+
"; session_log_path method added to CodexSessionLocator (+5 net lines)"
993+
"; process_idle_timeout_ms field wired through build_skill_session_cmd and "
994+
"build_food_truck_cmd CmdSpec constructors (+8 net lines)",
993995
),
994996
}
995997

tests/core/test_backend_dataclasses.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def test_cmd_spec_fields():
2323
from autoskillit.core import CmdSpec
2424

2525
fields = {f.name for f in dataclasses.fields(CmdSpec)}
26-
assert fields == {"cmd", "env", "cwd", "origin", "is_resume"}
26+
assert fields == {"cmd", "env", "cwd", "origin", "is_resume", "process_idle_timeout_ms"}
2727

2828

2929
def test_cmd_spec_is_resume_default():
@@ -33,6 +33,13 @@ def test_cmd_spec_is_resume_default():
3333
assert spec.is_resume is False
3434

3535

36+
def test_cmd_spec_process_idle_timeout_default():
37+
from autoskillit.core import CmdSpec
38+
39+
spec = CmdSpec(cmd=(), env={})
40+
assert spec.process_idle_timeout_ms == 0
41+
42+
3643
def test_cmd_spec_env_accepts_mapping():
3744
from collections.abc import Mapping
3845

tests/execution/backends/test_codex_backend.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,13 @@ def test_claude_only_params_accepted_but_ignored(self) -> None:
626626
assert "--plugin-dir" not in cmd_str
627627
assert "CLAUDE_CODE_EXIT_AFTER_STOP_DELAY" not in spec.env
628628
assert "CLAUDE_STREAM_IDLE_TIMEOUT_MS" not in spec.env
629+
assert spec.process_idle_timeout_ms == 3000
630+
631+
def test_stream_idle_timeout_routed_to_cmdspec(self) -> None:
632+
spec = CodexBackend().build_skill_session_cmd(
633+
**{**self.BASE, "stream_idle_timeout_ms": 30000}
634+
)
635+
assert spec.process_idle_timeout_ms == 30000
629636

630637
def test_cwd_set(self) -> None:
631638
spec = CodexBackend().build_skill_session_cmd(
@@ -1078,6 +1085,12 @@ def test_bypass_hook_trust_present_in_food_truck_cmd(self) -> None:
10781085
spec = CodexBackend().build_food_truck_cmd(**self.BASE)
10791086
assert "--dangerously-bypass-hook-trust" in spec.cmd
10801087

1088+
def test_stream_idle_timeout_routed_to_cmdspec(self) -> None:
1089+
spec = CodexBackend().build_food_truck_cmd(
1090+
**{**self.BASE, "stream_idle_timeout_ms": 60000}
1091+
)
1092+
assert spec.process_idle_timeout_ms == 60000
1093+
10811094

10821095
class TestCodexEnsurePreLaunchConfigValidation:
10831096
@pytest.fixture(autouse=True)
@@ -1516,6 +1529,73 @@ def test_conventions_skills_subdir(self) -> None:
15161529
assert CodexBackend().conventions.skills_subdir == Path("skills")
15171530

15181531

1532+
class TestClaudeCodeBackendProcessIdleDefault:
1533+
def test_claude_code_backend_process_idle_default_zero(self) -> None:
1534+
from autoskillit.execution.backends.claude import ClaudeCodeBackend
1535+
1536+
spec = ClaudeCodeBackend().build_skill_session_cmd(
1537+
"/test-skill",
1538+
cwd="/work",
1539+
completion_marker="%%DONE%%",
1540+
)
1541+
assert spec.process_idle_timeout_ms == 0
1542+
1543+
1544+
class TestCodexDiscardDispositions:
1545+
"""Document the complete noqa:F841 discard disposition picture in codex.py.
1546+
1547+
stream_idle_timeout_ms -> routed to CmdSpec.process_idle_timeout_ms (P1-A6-WP1).
1548+
plugin_source, output_format, exit_after_stop_delay_ms -> intentional
1549+
no-op discards (P2-A2 scope).
1550+
"""
1551+
1552+
def test_stream_idle_timeout_routed_in_skill_builder(self) -> None:
1553+
spec = CodexBackend().build_skill_session_cmd(
1554+
skill_command="/test",
1555+
cwd="/work",
1556+
completion_marker="%%DONE%%",
1557+
stream_idle_timeout_ms=10000,
1558+
)
1559+
assert spec.process_idle_timeout_ms == 10000
1560+
1561+
def test_stream_idle_timeout_routed_in_food_truck_builder(self) -> None:
1562+
spec = CodexBackend().build_food_truck_cmd(
1563+
orchestrator_prompt="go",
1564+
plugin_source=DirectInstall(plugin_dir=Path("/pkg")),
1565+
cwd="/work",
1566+
completion_marker="%%DONE%%",
1567+
stream_idle_timeout_ms=20000,
1568+
)
1569+
assert spec.process_idle_timeout_ms == 20000
1570+
1571+
def test_exit_after_stop_delay_still_discarded(self) -> None:
1572+
spec = CodexBackend().build_skill_session_cmd(
1573+
skill_command="/test",
1574+
cwd="/work",
1575+
completion_marker="%%DONE%%",
1576+
exit_after_stop_delay_ms=5000,
1577+
)
1578+
assert "CLAUDE_CODE_EXIT_AFTER_STOP_DELAY" not in spec.env
1579+
1580+
def test_plugin_source_still_discarded(self) -> None:
1581+
spec = CodexBackend().build_skill_session_cmd(
1582+
skill_command="/test",
1583+
cwd="/work",
1584+
completion_marker="%%DONE%%",
1585+
plugin_source=DirectInstall(plugin_dir=Path("/pkg")),
1586+
)
1587+
assert "--plugin-dir" not in " ".join(spec.cmd)
1588+
1589+
def test_output_format_still_discarded(self) -> None:
1590+
spec = CodexBackend().build_skill_session_cmd(
1591+
skill_command="/test",
1592+
cwd="/work",
1593+
completion_marker="%%DONE%%",
1594+
output_format=OutputFormat.STREAM_JSON,
1595+
)
1596+
assert "--json" in spec.cmd
1597+
1598+
15191599
class TestCodexBackendSetupSessionDir:
15201600
@pytest.fixture(autouse=True)
15211601
def _setup_dirs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:

tests/execution/test_headless_execute.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,35 @@
22

33
from __future__ import annotations
44

5+
import json
6+
from pathlib import Path
7+
58
import pytest
69

710
from autoskillit.core import CmdSpec
11+
from autoskillit.core.types import SubprocessResult, TerminationReason
812

913
pytestmark = [pytest.mark.layer("execution"), pytest.mark.small]
1014

1115

16+
def _success_result() -> SubprocessResult:
17+
return SubprocessResult(
18+
returncode=0,
19+
stdout=json.dumps(
20+
{
21+
"type": "result",
22+
"subtype": "success",
23+
"is_error": False,
24+
"result": "done",
25+
"session_id": "sess-idle-test",
26+
}
27+
),
28+
stderr="",
29+
termination=TerminationReason.NATURAL_EXIT,
30+
pid=12345,
31+
)
32+
33+
1234
def test_assert_headless_cmd_passes_with_p_flag() -> None:
1335
from autoskillit.execution.headless._headless_helpers import assert_headless_cmd
1436

@@ -32,3 +54,85 @@ def test_assert_headless_cmd_empty_cmd_no_error() -> None:
3254
from autoskillit.execution.headless._headless_helpers import assert_headless_cmd
3355

3456
assert_headless_cmd(CmdSpec(cmd=(), env={}))
57+
58+
59+
class TestProcessIdleTimeoutOverride:
60+
"""Tests for CmdSpec.process_idle_timeout_ms overriding effective_idle."""
61+
62+
@pytest.mark.anyio
63+
async def test_spec_idle_used_when_caller_supplies_none(
64+
self, minimal_ctx, tmp_path: Path, monkeypatch
65+
) -> None:
66+
from autoskillit.execution.headless import run_headless_core
67+
from tests.execution.conftest import _mock_backend
68+
from tests.fakes import MockSubprocessRunner
69+
70+
monkeypatch.delenv("AUTOSKILLIT_IDLE_OUTPUT_TIMEOUT", raising=False)
71+
runner = MockSubprocessRunner()
72+
runner.set_default(_success_result())
73+
minimal_ctx.runner = runner
74+
backend = _mock_backend(pty_required=True, channel_b_capable=True)
75+
backend.build_skill_session_cmd.return_value = CmdSpec(
76+
cmd=("claude", "-p", "test"),
77+
env={},
78+
process_idle_timeout_ms=30000,
79+
)
80+
minimal_ctx.backend = backend
81+
82+
await run_headless_core("/test foo", str(tmp_path), minimal_ctx)
83+
84+
assert runner.call_args_list, "runner was never called"
85+
_cmd, _cwd, _timeout, kwargs = runner.call_args_list[0]
86+
assert kwargs.get("idle_output_timeout") == 30.0
87+
88+
@pytest.mark.anyio
89+
async def test_spec_idle_overrides_when_smaller(
90+
self, minimal_ctx, tmp_path: Path, monkeypatch
91+
) -> None:
92+
from autoskillit.execution.headless import run_headless_core
93+
from tests.execution.conftest import _mock_backend
94+
from tests.fakes import MockSubprocessRunner
95+
96+
monkeypatch.setenv("AUTOSKILLIT_IDLE_OUTPUT_TIMEOUT", "45")
97+
runner = MockSubprocessRunner()
98+
runner.set_default(_success_result())
99+
minimal_ctx.runner = runner
100+
backend = _mock_backend(pty_required=True, channel_b_capable=True)
101+
backend.build_skill_session_cmd.return_value = CmdSpec(
102+
cmd=("claude", "-p", "test"),
103+
env={},
104+
process_idle_timeout_ms=15000,
105+
)
106+
minimal_ctx.backend = backend
107+
108+
await run_headless_core("/test foo", str(tmp_path), minimal_ctx)
109+
110+
assert runner.call_args_list, "runner was never called"
111+
_cmd, _cwd, _timeout, kwargs = runner.call_args_list[0]
112+
assert kwargs.get("idle_output_timeout") == 15.0
113+
114+
@pytest.mark.anyio
115+
async def test_zero_spec_idle_leaves_effective_unaffected(
116+
self, minimal_ctx, tmp_path: Path, monkeypatch
117+
) -> None:
118+
from autoskillit.execution.headless import run_headless_core
119+
from tests.execution.conftest import _mock_backend
120+
from tests.fakes import MockSubprocessRunner
121+
122+
monkeypatch.setenv("AUTOSKILLIT_IDLE_OUTPUT_TIMEOUT", "30")
123+
runner = MockSubprocessRunner()
124+
runner.set_default(_success_result())
125+
minimal_ctx.runner = runner
126+
backend = _mock_backend(pty_required=True, channel_b_capable=True)
127+
backend.build_skill_session_cmd.return_value = CmdSpec(
128+
cmd=("claude", "-p", "test"),
129+
env={},
130+
process_idle_timeout_ms=0,
131+
)
132+
minimal_ctx.backend = backend
133+
134+
await run_headless_core("/test foo", str(tmp_path), minimal_ctx)
135+
136+
assert runner.call_args_list, "runner was never called"
137+
_cmd, _cwd, _timeout, kwargs = runner.call_args_list[0]
138+
assert kwargs.get("idle_output_timeout") == 30.0

0 commit comments

Comments
 (0)