-
Notifications
You must be signed in to change notification settings - Fork 0
Add idle_output_timeout as Per-Step Recipe Override #793
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f000bf8
bc35d1d
daf5bc9
84168b8
5f0d390
20b9d47
501717b
c5da48b
0141f7b
e8e772a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,12 @@ 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) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [warning] defense: float(cfg.idle_output_timeout) is called unconditionally with no error context. If dynaconf surfaces this as a string via an env-var override, the resulting ValueError is uncontextualised. Validate the type in the config dataclass or wrap with a descriptive try/except.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Investigated — this is intentional. settings.py:402 performs |
||
| ) | ||
| effective_idle: float | None = _raw_idle if _raw_idle > 0.0 else None | ||
|
|
||
| logger.debug( | ||
| "run_headless_core_entry", | ||
|
|
@@ -1023,7 +1030,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 +1188,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 = "", | ||
|
|
@@ -1199,6 +1207,7 @@ async def run( | |
| add_dirs=add_dirs, | ||
| timeout=effective_timeout, | ||
| stale_threshold=effective_stale, | ||
| idle_output_timeout=idle_output_timeout, | ||
| expected_output_patterns=expected_output_patterns, | ||
| write_behavior=write_behavior, | ||
| completion_marker=completion_marker, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [warning] defense: float(idle_output_timeout) is called without guarding against non-numeric input. FastMCP deserialises from JSON; an unexpected type raises a confusing TypeError instead of a clean validation error. Add an isinstance guard or try/except with a descriptive message.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Investigated — this is intentional. Parameter typed as |
||
| if idle_output_timeout is not None | ||
| else None, | ||
| completion_marker=invocation_marker, | ||
| ) | ||
| if skill_result.success: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3560,6 +3560,75 @@ 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 | ||
| ) | ||
Trecek marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| _, _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 | ||
| ) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [critical] tests: Same fragile call_args_list 4-tuple unpacking issue as L3599.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Investigated — this is intentional. Same as L3599 — MockSubprocessRunner stores explicit 4-tuples, documented in conftest.py:49. Unpacking is correct. |
||
| _, _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 | ||
|
|
||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [warning] tests: Tautological assertion: cfg_value is read from the live config then asserted equal to the result. If the implementation simply reads the same config field, this always passes regardless of correctness. Assert against a known constant (e.g. == 600.0) to make the test meaningful.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed — fixed in e8e772a. Replaced |
||
| 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 | ||
| ) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [critical] tests: Same fragile call_args_list 4-tuple unpacking issue as L3599.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Investigated — this is intentional. Same as L3599 — MockSubprocessRunner stores explicit 4-tuples, documented in conftest.py:49. Unpacking is correct. |
||
| _, _cwd, _timeout, kwargs = tool_ctx.runner.call_args_list[0] | ||
| assert kwargs["idle_output_timeout"] == 600.0 | ||
|
|
||
|
|
||
| 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 = [] | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.