Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/autoskillit/core/_type_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "",
Expand Down
11 changes: 10 additions & 1 deletion src/autoskillit/execution/headless.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "",
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Investigated — this is intentional. settings.py:402 performs idle_output_timeout=int(val(...)) at config load time, coercing env-var overrides before they reach headless.py. By the time float(cfg.idle_output_timeout) is called at headless.py:995, the value is a validated int. An env-var string like 'abc' would raise ValueError at config load (settings.py), not here.

)
effective_idle: float | None = _raw_idle if _raw_idle > 0.0 else None

logger.debug(
"run_headless_core_entry",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 = "",
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/recipe/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__):
Expand Down Expand Up @@ -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"),
)


Expand Down
10 changes: 9 additions & 1 deletion src/autoskillit/recipe/rules_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@
_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"}),
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/recipe/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/autoskillit/recipe/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions src/autoskillit/recipes/research.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand All @@ -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 }}"
Expand Down Expand Up @@ -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 }}"
Expand All @@ -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 }}"
Expand Down Expand Up @@ -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 }}"
Expand Down
7 changes: 7 additions & 0 deletions src/autoskillit/server/tools_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Investigated — this is intentional. Parameter typed as int | None at tools_execution.py:154. FastMCP validates JSON schema types before invoking the function body; non-numeric inputs are rejected at the MCP transport layer with a schema error, never reaching the float() call.

if idle_output_timeout is not None
else None,
completion_marker=invocation_marker,
)
if skill_result.success:
Expand Down
69 changes: 69 additions & 0 deletions tests/execution/test_headless.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
_, _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
)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — fixed in e8e772a. Replaced cfg_value = float(tool_ctx.config.run_skill.idle_output_timeout) reference with a direct assertion against 600.0 (the known config default per defaults.yaml:47 and settings.py:96). Test now catches implementations that ignore the config entirely.

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
)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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 = []
Expand Down
14 changes: 14 additions & 0 deletions tests/pipeline/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ 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
Expand Down
15 changes: 15 additions & 0 deletions tests/recipe/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
31 changes: 31 additions & 0 deletions tests/recipe/test_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading