From f000bf8ca25ef71bcc87332dffaee3bdf14e30d0 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 12 Apr 2026 22:08:53 -0700 Subject: [PATCH 01/10] feat(recipe): add idle_output_timeout field to RecipeStep schema and parser Add idle_output_timeout: int | None = None to RecipeStep dataclass, register it in _PARSE_STEP_HANDLED_FIELDS, and thread it through _parse_step() via data.get(). 0 means disabled for this step; None means fall back to global config. --- src/autoskillit/recipe/io.py | 2 ++ src/autoskillit/recipe/schema.py | 1 + 2 files changed, 3 insertions(+) diff --git a/src/autoskillit/recipe/io.py b/src/autoskillit/recipe/io.py index 78015aa8..9ea047c4 100644 --- a/src/autoskillit/recipe/io.py +++ b/src/autoskillit/recipe/io.py @@ -148,6 +148,7 @@ def find_recipe_by_name(name: str, project_dir: Path) -> RecipeInfo | None: "gate", "optional_context_refs", "stale_threshold", + "idle_output_timeout", } ) if _PARSE_STEP_HANDLED_FIELDS != frozenset(RecipeStep.__dataclass_fields__): @@ -254,6 +255,7 @@ def _parse_step(data: dict[str, Any]) -> RecipeStep: gate=data.get("gate"), optional_context_refs=data.get("optional_context_refs", []), stale_threshold=data.get("stale_threshold"), + idle_output_timeout=data.get("idle_output_timeout"), ) diff --git a/src/autoskillit/recipe/schema.py b/src/autoskillit/recipe/schema.py index 06ef4a89..e2a1b7a4 100644 --- a/src/autoskillit/recipe/schema.py +++ b/src/autoskillit/recipe/schema.py @@ -89,6 +89,7 @@ class RecipeStep: default_factory=list ) # Context variable names that may be referenced before they are captured (cyclic routes) stale_threshold: int | None = None # None means use global RunSkillConfig.stale_threshold + idle_output_timeout: int | None = None # None = use global cfg; 0 = disabled for this step @dataclass From bc35d1d47a2091b379a58ed296e8a77a7e14a313 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 12 Apr 2026 22:09:18 -0700 Subject: [PATCH 02/10] feat(recipe): add idle_output_timeout validation and run_skill whitelist entry Validate that idle_output_timeout is a non-negative integer when set (0 is valid and means disabled). Add idle_output_timeout to the run_skill tool parameter whitelist so the recipe orchestrator can pass it as a kwarg. --- src/autoskillit/recipe/rules_tools.py | 3 ++- src/autoskillit/recipe/validator.py | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/recipe/rules_tools.py b/src/autoskillit/recipe/rules_tools.py index c654c6d3..94b80ae4 100644 --- a/src/autoskillit/recipe/rules_tools.py +++ b/src/autoskillit/recipe/rules_tools.py @@ -13,7 +13,8 @@ _TOOL_PARAMS: dict[str, frozenset[str]] = { # --- Execution tools --- "run_skill": frozenset( - {"skill_command", "cwd", "model", "step_name", "order_id", "stale_threshold"} + {"skill_command", "cwd", "model", "step_name", "order_id", "stale_threshold", + "idle_output_timeout"} ), "run_cmd": frozenset({"cmd", "cwd", "timeout", "step_name"}), "run_python": frozenset({"callable", "args", "timeout"}), diff --git a/src/autoskillit/recipe/validator.py b/src/autoskillit/recipe/validator.py index ac6372a9..1821a3c1 100644 --- a/src/autoskillit/recipe/validator.py +++ b/src/autoskillit/recipe/validator.py @@ -161,6 +161,14 @@ def validate_recipe(recipe: Recipe) -> list[str]: f"when set, got {step.stale_threshold!r}" ) + if step.idle_output_timeout is not None and ( + not isinstance(step.idle_output_timeout, int) or step.idle_output_timeout < 0 + ): + errors.append( + f"Step {step_name!r}: 'idle_output_timeout' must be a non-negative integer " + f"when set (0 = disabled), got {step.idle_output_timeout!r}" + ) + if step.on_result is not None: if step.on_success is not None: errors.append( From daf5bc930b47de0e5ee7fbe4346aacd7c216cc13 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 12 Apr 2026 22:09:35 -0700 Subject: [PATCH 03/10] feat(core): add idle_output_timeout to HeadlessExecutor protocol Extend HeadlessExecutor.run() protocol signature with optional idle_output_timeout: float | None = None parameter, matching the same pattern as stale_threshold. --- src/autoskillit/core/_type_protocols.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/autoskillit/core/_type_protocols.py b/src/autoskillit/core/_type_protocols.py index 4f3e6aaa..8e8e60aa 100644 --- a/src/autoskillit/core/_type_protocols.py +++ b/src/autoskillit/core/_type_protocols.py @@ -182,6 +182,7 @@ async def run( add_dirs: Sequence[ValidatedAddDir] = (), timeout: float | None = None, stale_threshold: float | None = None, + idle_output_timeout: float | None = None, expected_output_patterns: Sequence[str] = (), write_behavior: WriteBehaviorSpec | None = None, completion_marker: str = "", From 84168b8f5d0a3f0285b2691bc8c04e0c9e5ee472 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 12 Apr 2026 22:10:13 -0700 Subject: [PATCH 04/10] feat(server): thread idle_output_timeout through run_skill MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add idle_output_timeout: int | None = None parameter to run_skill(), convert to float and pass to executor.run(). The None passthrough is intentional; 0→None (disabled) conversion happens in the executor layer. --- src/autoskillit/server/tools_execution.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/autoskillit/server/tools_execution.py b/src/autoskillit/server/tools_execution.py index 29752a69..c0f77b63 100644 --- a/src/autoskillit/server/tools_execution.py +++ b/src/autoskillit/server/tools_execution.py @@ -151,6 +151,7 @@ async def run_skill( step_name: str = "", order_id: str = "", stale_threshold: int | None = None, + idle_output_timeout: int | None = None, ctx: Context = CurrentContext(), ) -> str: """Run a Claude Code headless session with a skill command. @@ -190,6 +191,9 @@ async def run_skill( stale_threshold: Override the staleness kill threshold in seconds. When set on a RecipeStep, the recipe orchestrator passes it here. None uses the global config default (RunSkillConfig.stale_threshold, default 1200s). + idle_output_timeout: Override the idle stdout kill threshold in seconds. + 0 = disabled for this step. None = use global config + (RunSkillConfig.idle_output_timeout, default 600s). """ if (headless := _require_not_headless("run_skill")) is not None: return headless @@ -296,6 +300,7 @@ async def run_skill( expected_output_patterns=expected_output_patterns, write_behavior=write_spec, stale_threshold=float(stale_threshold) if stale_threshold is not None else None, + idle_output_timeout=float(idle_output_timeout) if idle_output_timeout is not None else None, completion_marker=invocation_marker, ) if skill_result.success: From 5f0d3903d12d673f75080be5cba6c77375d5429e Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 12 Apr 2026 22:10:56 -0700 Subject: [PATCH 05/10] feat(execution): thread idle_output_timeout through headless executor layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add idle_output_timeout: float | None = None to run_headless_core() and DefaultHeadlessExecutor.run(). Both layers apply the same 0→None resolution (raw=0 means disabled, maps to None for run_managed_async). When not provided, falls back to float(cfg.idle_output_timeout). --- src/autoskillit/execution/headless.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/execution/headless.py b/src/autoskillit/execution/headless.py index 15d311ee..0e758da2 100644 --- a/src/autoskillit/execution/headless.py +++ b/src/autoskillit/execution/headless.py @@ -953,6 +953,7 @@ async def run_headless_core( add_dirs: Sequence[ValidatedAddDir] = (), timeout: float | None = None, stale_threshold: float | None = None, + idle_output_timeout: float | None = None, expected_output_patterns: Sequence[str] = (), write_behavior: WriteBehaviorSpec | None = None, completion_marker: str = "", @@ -988,6 +989,8 @@ async def run_headless_core( effective_timeout = timeout if timeout is not None else cfg.timeout effective_stale = stale_threshold if stale_threshold is not None else cfg.stale_threshold + _raw_idle = idle_output_timeout if idle_output_timeout is not None else float(cfg.idle_output_timeout) + effective_idle: float | None = _raw_idle if _raw_idle > 0.0 else None logger.debug( "run_headless_core_entry", @@ -1023,7 +1026,7 @@ async def run_headless_core( stale_threshold=effective_stale, completion_drain_timeout=cfg.completion_drain_timeout, linux_tracing_config=linux_tracing_cfg, - idle_output_timeout=cfg.idle_output_timeout, + idle_output_timeout=effective_idle, max_suppression_seconds=cfg.max_suppression_seconds, ) finally: @@ -1181,6 +1184,7 @@ async def run( add_dirs: Sequence[ValidatedAddDir] = (), timeout: float | None = None, stale_threshold: float | None = None, + idle_output_timeout: float | None = None, expected_output_patterns: Sequence[str] = (), write_behavior: WriteBehaviorSpec | None = None, completion_marker: str = "", @@ -1188,6 +1192,8 @@ async def run( cfg = self._ctx.config.run_skill effective_timeout = timeout if timeout is not None else cfg.timeout effective_stale = stale_threshold if stale_threshold is not None else cfg.stale_threshold + _raw_idle = idle_output_timeout if idle_output_timeout is not None else float(cfg.idle_output_timeout) + effective_idle: float | None = _raw_idle if _raw_idle > 0.0 else None return await run_headless_core( skill_command, cwd, @@ -1199,6 +1205,7 @@ async def run( add_dirs=add_dirs, timeout=effective_timeout, stale_threshold=effective_stale, + idle_output_timeout=effective_idle, expected_output_patterns=expected_output_patterns, write_behavior=write_behavior, completion_marker=completion_marker, From 20b9d47f03fa6684778a2cfae56095d5a0b6ba0a Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 12 Apr 2026 22:11:52 -0700 Subject: [PATCH 06/10] feat(recipes): disable idle_output_timeout on 5 compute-heavy research steps Add idle_output_timeout: 0 (disabled) to implement_phase, troubleshoot_implement_failure, run_experiment, adjust_experiment, and re_run_experiment. These steps use skills that may produce no stdout for long periods (LLM thinking, compilation, experiments). --- src/autoskillit/recipes/research.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/autoskillit/recipes/research.yaml b/src/autoskillit/recipes/research.yaml index 85157fae..c881e5de 100644 --- a/src/autoskillit/recipes/research.yaml +++ b/src/autoskillit/recipes/research.yaml @@ -293,6 +293,7 @@ steps: implement_phase: tool: run_skill stale_threshold: 2400 + idle_output_timeout: 0 with: skill_command: "/autoskillit:implement-experiment ${{ context.phase_plan_path }}" cwd: "${{ context.worktree_path }}" @@ -312,6 +313,7 @@ steps: troubleshoot_implement_failure: tool: run_skill stale_threshold: 2400 + idle_output_timeout: 0 with: skill_command: "/autoskillit:troubleshoot-experiment ${{ context.worktree_path }} implement_phase" cwd: "${{ inputs.source_dir }}" @@ -343,6 +345,7 @@ steps: run_experiment: tool: run_skill stale_threshold: 2400 + idle_output_timeout: 0 with: skill_command: "/autoskillit:run-experiment ${{ context.worktree_path }}" cwd: "${{ context.worktree_path }}" @@ -357,6 +360,7 @@ steps: adjust_experiment: tool: run_skill stale_threshold: 2400 + idle_output_timeout: 0 with: skill_command: "/autoskillit:run-experiment ${{ context.worktree_path }} --adjust" cwd: "${{ context.worktree_path }}" @@ -661,6 +665,7 @@ steps: re_run_experiment: tool: run_skill stale_threshold: 2400 + idle_output_timeout: 0 with: skill_command: "/autoskillit:run-experiment ${{ context.worktree_path }} --adjust" cwd: "${{ context.worktree_path }}" From 501717b0f2509d1c4170f6872ac699cc362fb013 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 12 Apr 2026 22:15:03 -0700 Subject: [PATCH 07/10] test: add tests for idle_output_timeout (T1-T6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1: RecipeStep parses idle_output_timeout from YAML (120, None, 0) T2: Validator rejects negative values, accepts 0 and positive T3: HeadlessExecutor protocol includes idle_output_timeout param T4/T5: run_skill passes idle_output_timeout to executor (120→120.0, None→None) T6: run_headless_core applies 0→None conversion and cfg fallback Also update MockExecutor signatures in existing server tests. --- tests/execution/test_headless.py | 72 ++++++++++++++++++++++++++++ tests/pipeline/test_context.py | 12 +++++ tests/recipe/test_io.py | 15 ++++++ tests/recipe/test_validator.py | 31 ++++++++++++ tests/server/test_tools_execution.py | 66 +++++++++++++++++++++++++ 5 files changed, 196 insertions(+) diff --git a/tests/execution/test_headless.py b/tests/execution/test_headless.py index da8bf2cb..63e922e1 100644 --- a/tests/execution/test_headless.py +++ b/tests/execution/test_headless.py @@ -3560,6 +3560,78 @@ def test_headless_executor_accepts_completion_marker(self) -> None: assert param.default == "" +class TestHeadlessExecutorIdleOutputTimeout: + """Protocol conformance and resolution logic for idle_output_timeout.""" + + def test_headless_executor_accepts_idle_output_timeout(self) -> None: + import inspect + + from autoskillit.execution.headless import DefaultHeadlessExecutor + + sig = inspect.signature(DefaultHeadlessExecutor.run) + assert "idle_output_timeout" in sig.parameters + param = sig.parameters["idle_output_timeout"] + assert param.default is None + + def _success_payload(self, marker: str) -> SubprocessResult: + payload = json.dumps( + { + "type": "result", + "subtype": "success", + "is_error": False, + "result": f"Done. {marker}", + "session_id": "sess-iot", + } + ) + return SubprocessResult(0, payload, "", TerminationReason.NATURAL_EXIT, pid=1) + + @pytest.mark.anyio + async def test_default_headless_executor_uses_per_step_idle_output_timeout( + self, tool_ctx + ) -> None: + """idle_output_timeout=120 is converted to float and passed to the runner.""" + from autoskillit.execution.headless import run_headless_core + + marker = tool_ctx.config.run_skill.completion_marker + tool_ctx.runner.push(self._success_payload(marker)) + await run_headless_core( + "/investigate foo", cwd="/tmp", ctx=tool_ctx, idle_output_timeout=120.0 + ) + _, _cwd, _timeout, kwargs = tool_ctx.runner.call_args_list[0] + assert kwargs["idle_output_timeout"] == 120.0 + + @pytest.mark.anyio + async def test_default_headless_executor_converts_zero_to_none( + self, tool_ctx + ) -> None: + """idle_output_timeout=0 is converted to None (disabled) before passing to runner.""" + from autoskillit.execution.headless import run_headless_core + + marker = tool_ctx.config.run_skill.completion_marker + tool_ctx.runner.push(self._success_payload(marker)) + await run_headless_core( + "/investigate foo", cwd="/tmp", ctx=tool_ctx, idle_output_timeout=0.0 + ) + _, _cwd, _timeout, kwargs = tool_ctx.runner.call_args_list[0] + assert kwargs["idle_output_timeout"] is None + + @pytest.mark.anyio + async def test_default_headless_executor_falls_back_to_cfg_idle_output_timeout( + self, tool_ctx + ) -> None: + """idle_output_timeout=None falls back to float(cfg.idle_output_timeout).""" + from autoskillit.execution.headless import run_headless_core + + cfg_value = float(tool_ctx.config.run_skill.idle_output_timeout) + marker = tool_ctx.config.run_skill.completion_marker + tool_ctx.runner.push(self._success_payload(marker)) + await run_headless_core( + "/investigate foo", cwd="/tmp", ctx=tool_ctx, idle_output_timeout=None + ) + _, _cwd, _timeout, kwargs = tool_ctx.runner.call_args_list[0] + assert kwargs["idle_output_timeout"] == cfg_value + + def _ndjson_with_write(result_text: str, file_paths: list[str], session_id: str = "test-session"): """Build NDJSON stdout with Write tool_use entries and a result record.""" records = [] diff --git a/tests/pipeline/test_context.py b/tests/pipeline/test_context.py index eb0d9a30..e5ce7796 100644 --- a/tests/pipeline/test_context.py +++ b/tests/pipeline/test_context.py @@ -158,6 +158,18 @@ def test_headless_executor_protocol_accepts_timeout() -> None: assert params["stale_threshold"].default is None +def test_headless_executor_protocol_accepts_idle_output_timeout() -> None: + """HeadlessExecutor.run() signature must include optional idle_output_timeout.""" + import inspect + + from autoskillit.core import HeadlessExecutor + + sig = inspect.signature(HeadlessExecutor.run) + params = sig.parameters + assert "idle_output_timeout" in params, "HeadlessExecutor.run missing idle_output_timeout param" + assert params["idle_output_timeout"].default is None + + def test_recipe_repository_protocol_has_rich_methods() -> None: """RecipeRepository protocol must expose load_and_validate, validate_from_path, list_all.""" from autoskillit.core import RecipeRepository diff --git a/tests/recipe/test_io.py b/tests/recipe/test_io.py index 6bd9cb77..7b852f5b 100644 --- a/tests/recipe/test_io.py +++ b/tests/recipe/test_io.py @@ -443,6 +443,21 @@ def test_parse_step_stale_threshold_defaults_to_none(self) -> None: step = _parse_step(data) assert step.stale_threshold is None + def test_parse_step_reads_idle_output_timeout(self) -> None: + data = {"tool": "run_skill", "idle_output_timeout": 120, "on_success": "done"} + step = _parse_step(data) + assert step.idle_output_timeout == 120 + + def test_parse_step_idle_output_timeout_defaults_to_none(self) -> None: + data = {"tool": "run_skill", "on_success": "done"} + step = _parse_step(data) + assert step.idle_output_timeout is None + + def test_parse_step_idle_output_timeout_zero_means_disabled(self) -> None: + data = {"tool": "run_skill", "idle_output_timeout": 0, "on_success": "done"} + step = _parse_step(data) + assert step.idle_output_timeout == 0 + # MOD4 def test_bundled_resolve_failures_steps_use_config_default(self) -> None: bd = builtin_recipes_dir() diff --git a/tests/recipe/test_validator.py b/tests/recipe/test_validator.py index 4af43bfa..aa321648 100644 --- a/tests/recipe/test_validator.py +++ b/tests/recipe/test_validator.py @@ -397,6 +397,37 @@ def test_validator_accepts_positive_stale_threshold(self) -> None: errors = validate_recipe(recipe) assert not any("stale_threshold" in e for e in errors) + def test_validator_rejects_negative_idle_output_timeout(self) -> None: + recipe = Recipe( + name="test", + description="test", + steps={"s": RecipeStep(tool="run_skill", on_success="done", idle_output_timeout=-1)}, + kitchen_rules=["test"], + ) + errors = validate_recipe(recipe) + assert any("idle_output_timeout" in e for e in errors) + + def test_validator_accepts_zero_idle_output_timeout(self) -> None: + # 0 = disabled, must NOT be rejected + recipe = Recipe( + name="test", + description="test", + steps={"s": RecipeStep(tool="run_skill", on_success="done", idle_output_timeout=0)}, + kitchen_rules=["test"], + ) + errors = validate_recipe(recipe) + assert not any("idle_output_timeout" in e for e in errors) + + def test_validator_accepts_positive_idle_output_timeout(self) -> None: + recipe = Recipe( + name="test", + description="test", + steps={"s": RecipeStep(tool="run_skill", on_success="done", idle_output_timeout=120)}, + kitchen_rules=["test"], + ) + errors = validate_recipe(recipe) + assert not any("idle_output_timeout" in e for e in errors) + # --------------------------------------------------------------------------- # TestDataFlowQuality — migrated from test_recipe_parser.py diff --git a/tests/server/test_tools_execution.py b/tests/server/test_tools_execution.py index 0dae3dcd..eee5ce0c 100644 --- a/tests/server/test_tools_execution.py +++ b/tests/server/test_tools_execution.py @@ -796,6 +796,7 @@ async def run( order_id: str = "", timeout: float | None = None, stale_threshold: float | None = None, + idle_output_timeout: float | None = None, expected_output_patterns: tuple[str, ...] | list[str] = (), write_behavior=None, completion_marker: str = "", @@ -843,6 +844,7 @@ async def run( order_id: str = "", timeout: float | None = None, stale_threshold: float | None = None, + idle_output_timeout: float | None = None, expected_output_patterns: tuple[str, ...] | list[str] = (), write_behavior=None, completion_marker: str = "", @@ -1441,3 +1443,67 @@ async def run(self, skill_command, cwd, *, add_dirs=(), **kwargs) -> SkillResult assert "mermaid" in closure arch_members = {n for n in closure if n.startswith("arch-lens-")} assert len(arch_members) >= 1 + + +@pytest.mark.anyio +async def test_run_skill_passes_idle_output_timeout(tool_ctx, monkeypatch) -> None: + """run_skill passes idle_output_timeout (as float) to executor.run().""" + from autoskillit.core import SkillResult + + captured: dict = {} + + class MockExecutor: + async def run(self, skill_command, cwd, *, idle_output_timeout=None, **kwargs) -> SkillResult: + captured["idle_output_timeout"] = idle_output_timeout + return SkillResult( + success=True, + result="ok", + session_id="", + subtype="success", + is_error=False, + exit_code=0, + needs_retry=False, + retry_reason="none", + stderr="", + token_usage=None, + ) + + tool_ctx.executor = MockExecutor() + monkeypatch.setattr("autoskillit.server._ctx", tool_ctx) + + from autoskillit.server.tools_execution import run_skill + + await run_skill("/test skill", "/tmp", idle_output_timeout=120) + assert captured["idle_output_timeout"] == 120.0 # int→float conversion + + +@pytest.mark.anyio +async def test_run_skill_idle_output_timeout_defaults_to_none(tool_ctx, monkeypatch) -> None: + """run_skill passes None to executor.run() when idle_output_timeout is not set.""" + from autoskillit.core import SkillResult + + captured: dict = {} + + class MockExecutor: + async def run(self, skill_command, cwd, *, idle_output_timeout=None, **kwargs) -> SkillResult: + captured["idle_output_timeout"] = idle_output_timeout + return SkillResult( + success=True, + result="ok", + session_id="", + subtype="success", + is_error=False, + exit_code=0, + needs_retry=False, + retry_reason="none", + stderr="", + token_usage=None, + ) + + tool_ctx.executor = MockExecutor() + monkeypatch.setattr("autoskillit.server._ctx", tool_ctx) + + from autoskillit.server.tools_execution import run_skill + + await run_skill("/test skill", "/tmp") + assert captured["idle_output_timeout"] is None From c5da48baf8a705b64eaaa667eed909c2199d7148 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 12 Apr 2026 22:15:31 -0700 Subject: [PATCH 08/10] style: apply ruff format auto-fixes Reformatted rules_tools.py, tools_execution.py, and test_context.py to comply with ruff line-length and style rules. --- src/autoskillit/execution/headless.py | 12 ++++++++++-- src/autoskillit/recipe/rules_tools.py | 11 +++++++++-- src/autoskillit/server/tools_execution.py | 4 +++- tests/execution/test_headless.py | 4 +--- tests/pipeline/test_context.py | 4 +++- tests/server/test_tools_execution.py | 8 ++++++-- 6 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/autoskillit/execution/headless.py b/src/autoskillit/execution/headless.py index 0e758da2..44fc1b68 100644 --- a/src/autoskillit/execution/headless.py +++ b/src/autoskillit/execution/headless.py @@ -989,7 +989,11 @@ async def run_headless_core( effective_timeout = timeout if timeout is not None else cfg.timeout effective_stale = stale_threshold if stale_threshold is not None else cfg.stale_threshold - _raw_idle = idle_output_timeout if idle_output_timeout is not None else float(cfg.idle_output_timeout) + _raw_idle = ( + idle_output_timeout + if idle_output_timeout is not None + else float(cfg.idle_output_timeout) + ) effective_idle: float | None = _raw_idle if _raw_idle > 0.0 else None logger.debug( @@ -1192,7 +1196,11 @@ async def run( cfg = self._ctx.config.run_skill effective_timeout = timeout if timeout is not None else cfg.timeout effective_stale = stale_threshold if stale_threshold is not None else cfg.stale_threshold - _raw_idle = idle_output_timeout if idle_output_timeout is not None else float(cfg.idle_output_timeout) + _raw_idle = ( + idle_output_timeout + if idle_output_timeout is not None + else float(cfg.idle_output_timeout) + ) effective_idle: float | None = _raw_idle if _raw_idle > 0.0 else None return await run_headless_core( skill_command, diff --git a/src/autoskillit/recipe/rules_tools.py b/src/autoskillit/recipe/rules_tools.py index 94b80ae4..ab92a287 100644 --- a/src/autoskillit/recipe/rules_tools.py +++ b/src/autoskillit/recipe/rules_tools.py @@ -13,8 +13,15 @@ _TOOL_PARAMS: dict[str, frozenset[str]] = { # --- Execution tools --- "run_skill": frozenset( - {"skill_command", "cwd", "model", "step_name", "order_id", "stale_threshold", - "idle_output_timeout"} + { + "skill_command", + "cwd", + "model", + "step_name", + "order_id", + "stale_threshold", + "idle_output_timeout", + } ), "run_cmd": frozenset({"cmd", "cwd", "timeout", "step_name"}), "run_python": frozenset({"callable", "args", "timeout"}), diff --git a/src/autoskillit/server/tools_execution.py b/src/autoskillit/server/tools_execution.py index c0f77b63..38151079 100644 --- a/src/autoskillit/server/tools_execution.py +++ b/src/autoskillit/server/tools_execution.py @@ -300,7 +300,9 @@ async def run_skill( expected_output_patterns=expected_output_patterns, write_behavior=write_spec, stale_threshold=float(stale_threshold) if stale_threshold is not None else None, - idle_output_timeout=float(idle_output_timeout) if idle_output_timeout is not None else None, + idle_output_timeout=float(idle_output_timeout) + if idle_output_timeout is not None + else None, completion_marker=invocation_marker, ) if skill_result.success: diff --git a/tests/execution/test_headless.py b/tests/execution/test_headless.py index 63e922e1..8f5cfc2f 100644 --- a/tests/execution/test_headless.py +++ b/tests/execution/test_headless.py @@ -3601,9 +3601,7 @@ async def test_default_headless_executor_uses_per_step_idle_output_timeout( assert kwargs["idle_output_timeout"] == 120.0 @pytest.mark.anyio - async def test_default_headless_executor_converts_zero_to_none( - self, tool_ctx - ) -> None: + async def test_default_headless_executor_converts_zero_to_none(self, tool_ctx) -> None: """idle_output_timeout=0 is converted to None (disabled) before passing to runner.""" from autoskillit.execution.headless import run_headless_core diff --git a/tests/pipeline/test_context.py b/tests/pipeline/test_context.py index e5ce7796..e22675bc 100644 --- a/tests/pipeline/test_context.py +++ b/tests/pipeline/test_context.py @@ -166,7 +166,9 @@ def test_headless_executor_protocol_accepts_idle_output_timeout() -> None: sig = inspect.signature(HeadlessExecutor.run) params = sig.parameters - assert "idle_output_timeout" in params, "HeadlessExecutor.run missing idle_output_timeout param" + assert "idle_output_timeout" in params, ( + "HeadlessExecutor.run missing idle_output_timeout param" + ) assert params["idle_output_timeout"].default is None diff --git a/tests/server/test_tools_execution.py b/tests/server/test_tools_execution.py index eee5ce0c..24720b8b 100644 --- a/tests/server/test_tools_execution.py +++ b/tests/server/test_tools_execution.py @@ -1453,7 +1453,9 @@ async def test_run_skill_passes_idle_output_timeout(tool_ctx, monkeypatch) -> No captured: dict = {} class MockExecutor: - async def run(self, skill_command, cwd, *, idle_output_timeout=None, **kwargs) -> SkillResult: + async def run( + self, skill_command, cwd, *, idle_output_timeout=None, **kwargs + ) -> SkillResult: captured["idle_output_timeout"] = idle_output_timeout return SkillResult( success=True, @@ -1485,7 +1487,9 @@ async def test_run_skill_idle_output_timeout_defaults_to_none(tool_ctx, monkeypa captured: dict = {} class MockExecutor: - async def run(self, skill_command, cwd, *, idle_output_timeout=None, **kwargs) -> SkillResult: + async def run( + self, skill_command, cwd, *, idle_output_timeout=None, **kwargs + ) -> SkillResult: captured["idle_output_timeout"] = idle_output_timeout return SkillResult( success=True, From 0141f7b1671ab5b5ed1eec6f053be8e824ff192d Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 12 Apr 2026 23:08:42 -0700 Subject: [PATCH 09/10] fix(review): remove double-resolution of idle_output_timeout in DefaultHeadlessExecutor.run() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DefaultHeadlessExecutor.run() was pre-resolving idle_output_timeout (0.0→None) before passing it to run_headless_core(). run_headless_core() then treated None as "not supplied" and fell back to float(cfg.idle_output_timeout), re-enabling the very timeout the step intended to disable. Fix: pass raw idle_output_timeout directly to run_headless_core(), which is the single authoritative resolution layer. Co-Authored-By: Claude Sonnet 4.6 --- src/autoskillit/execution/headless.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/autoskillit/execution/headless.py b/src/autoskillit/execution/headless.py index 44fc1b68..4e506e34 100644 --- a/src/autoskillit/execution/headless.py +++ b/src/autoskillit/execution/headless.py @@ -1196,12 +1196,6 @@ async def run( cfg = self._ctx.config.run_skill effective_timeout = timeout if timeout is not None else cfg.timeout effective_stale = stale_threshold if stale_threshold is not None else cfg.stale_threshold - _raw_idle = ( - idle_output_timeout - if idle_output_timeout is not None - else float(cfg.idle_output_timeout) - ) - effective_idle: float | None = _raw_idle if _raw_idle > 0.0 else None return await run_headless_core( skill_command, cwd, @@ -1213,7 +1207,7 @@ async def run( add_dirs=add_dirs, timeout=effective_timeout, stale_threshold=effective_stale, - idle_output_timeout=effective_idle, + idle_output_timeout=idle_output_timeout, expected_output_patterns=expected_output_patterns, write_behavior=write_behavior, completion_marker=completion_marker, From e8e772a4ebfe9589c40a7afb0c251906ff1b85e8 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 12 Apr 2026 23:09:14 -0700 Subject: [PATCH 10/10] fix(review): assert 600.0 constant instead of reading cfg in idle_output_timeout fallback test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous assertion read float(cfg.idle_output_timeout) from the same config the implementation reads, making the test tautological — it would pass even if the implementation used an arbitrary config field. Assert the known default 600.0 directly. Co-Authored-By: Claude Sonnet 4.6 --- tests/execution/test_headless.py | 3 +-- tests/server/test_tools_execution.py | 39 +++++++++------------------- 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/tests/execution/test_headless.py b/tests/execution/test_headless.py index 8f5cfc2f..67bc5b77 100644 --- a/tests/execution/test_headless.py +++ b/tests/execution/test_headless.py @@ -3620,14 +3620,13 @@ async def test_default_headless_executor_falls_back_to_cfg_idle_output_timeout( """idle_output_timeout=None falls back to float(cfg.idle_output_timeout).""" from autoskillit.execution.headless import run_headless_core - cfg_value = float(tool_ctx.config.run_skill.idle_output_timeout) marker = tool_ctx.config.run_skill.completion_marker tool_ctx.runner.push(self._success_payload(marker)) await run_headless_core( "/investigate foo", cwd="/tmp", ctx=tool_ctx, idle_output_timeout=None ) _, _cwd, _timeout, kwargs = tool_ctx.runner.call_args_list[0] - assert kwargs["idle_output_timeout"] == cfg_value + assert kwargs["idle_output_timeout"] == 600.0 def _ndjson_with_write(result_text: str, file_paths: list[str], session_id: str = "test-session"): diff --git a/tests/server/test_tools_execution.py b/tests/server/test_tools_execution.py index 24720b8b..8d9cef9a 100644 --- a/tests/server/test_tools_execution.py +++ b/tests/server/test_tools_execution.py @@ -1445,9 +1445,8 @@ async def run(self, skill_command, cwd, *, add_dirs=(), **kwargs) -> SkillResult assert len(arch_members) >= 1 -@pytest.mark.anyio -async def test_run_skill_passes_idle_output_timeout(tool_ctx, monkeypatch) -> None: - """run_skill passes idle_output_timeout (as float) to executor.run().""" +def _make_capturing_executor(): + """Return (executor, captured_dict) for testing idle_output_timeout propagation.""" from autoskillit.core import SkillResult captured: dict = {} @@ -1470,7 +1469,14 @@ async def run( token_usage=None, ) - tool_ctx.executor = MockExecutor() + return MockExecutor(), captured + + +@pytest.mark.anyio +async def test_run_skill_passes_idle_output_timeout(tool_ctx, monkeypatch) -> None: + """run_skill passes idle_output_timeout (as float) to executor.run().""" + executor, captured = _make_capturing_executor() + tool_ctx.executor = executor monkeypatch.setattr("autoskillit.server._ctx", tool_ctx) from autoskillit.server.tools_execution import run_skill @@ -1482,29 +1488,8 @@ async def run( @pytest.mark.anyio async def test_run_skill_idle_output_timeout_defaults_to_none(tool_ctx, monkeypatch) -> None: """run_skill passes None to executor.run() when idle_output_timeout is not set.""" - from autoskillit.core import SkillResult - - captured: dict = {} - - class MockExecutor: - async def run( - self, skill_command, cwd, *, idle_output_timeout=None, **kwargs - ) -> SkillResult: - captured["idle_output_timeout"] = idle_output_timeout - return SkillResult( - success=True, - result="ok", - session_id="", - subtype="success", - is_error=False, - exit_code=0, - needs_retry=False, - retry_reason="none", - stderr="", - token_usage=None, - ) - - tool_ctx.executor = MockExecutor() + executor, captured = _make_capturing_executor() + tool_ctx.executor = executor monkeypatch.setattr("autoskillit.server._ctx", tool_ctx) from autoskillit.server.tools_execution import run_skill