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
6 changes: 6 additions & 0 deletions .autoskillit/test-source-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -15071,5 +15071,11 @@
"tests/server/test_tools_git.py",
"tests/server/test_tools_git_merge_cleanup.py",
"tests/workspace/test_worktree.py"
],
"src/autoskillit/_probe_canary.py": [
"tests/execution/backends/test_probe_canary.py"
],
"src/autoskillit/execution/backends/_probe_cache.py": [
"tests/execution/backends/test_probe_cache.py"
]
}
1 change: 1 addition & 0 deletions src/autoskillit/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Package root — entry points, hook registry, and cross-cutting utilities.
| `_llm_triage.py` | Contract staleness triage (Haiku subprocess) |
| `smoke_utils/` | Callables for smoke-test pipeline `run_python` steps (package) |
| `hook_registry.py` | `HookDef`, `HOOK_REGISTRY`, `generate_hooks_json` |
| `_probe_canary.py` | Canary state machine and GitHub issue updater for live probes (IL-1) |
| `_test_filter.py` | Test filter manifest: glob-to-test-directory mapping |
| `version.py` | Version health utilities (IL-0) |

Expand Down
167 changes: 167 additions & 0 deletions src/autoskillit/_probe_canary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Reusable canary state machine and GitHub issue updater for live probes.

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.

Observation from local review round 0:

[warning] cohesion: File bundles two unrelated responsibilities: (a) pure state machine (CanaryState, ErrorKind, N_CONSECUTIVE_FLAKE_GUARD) with no GitHub dependency, and (b) GitHub API caller (CanaryIssueUpdater with run_gh). Split into separate modules.

Evidence: The WP1 task description explicitly named 'canary state machine AND GitHub issue updater' as the deliverable — they were intentionally co-located. Splitting would affect existing importers and is a design decision requiring human judgment about whether the coupling is acceptable.

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.

Valid observation — flagged for design decision. The WP1 task description explicitly named 'canary state machine AND GitHub issue updater' as the deliverable — they were intentionally co-located. Splitting would affect existing importers and requires a human decision about whether the coupling is acceptable.


IL-1 module: imports only stdlib and `autoskillit.core`. Provides the
Comment thread
Trecek marked this conversation as resolved.
persistence + flake-guard primitives that live probe classes build on.
"""

from __future__ import annotations

import json
import os
import subprocess
import tempfile
from dataclasses import asdict, dataclass
from enum import StrEnum, unique
from pathlib import Path

from autoskillit.core import atomic_write, get_logger, run_gh

logger = get_logger(__name__)

N_CONSECUTIVE_FLAKE_GUARD: int = 3


@unique
class ErrorKind(StrEnum):
NETWORK = "network"
SCHEMA = "schema"


@dataclass
class CanaryState:
network_streak: int = 0
schema_streak: int = 0
last_issue_number: int | None = None

@classmethod
def load(cls, path: Path) -> CanaryState:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return cls()
if not isinstance(raw, dict):
return cls()
return cls(
network_streak=raw.get("network_streak", 0),
schema_streak=raw.get("schema_streak", 0),
last_issue_number=raw.get("last_issue_number"),
)

def save(self, path: Path) -> None:
atomic_write(path, json.dumps(asdict(self), indent=2))

def record_failure(self, kind: ErrorKind) -> None:
if kind is ErrorKind.NETWORK:
self.network_streak += 1
elif kind is ErrorKind.SCHEMA:
self.schema_streak += 1
else:
raise ValueError(f"Unhandled ErrorKind: {kind!r}")

def record_success(self) -> None:
self.network_streak = 0
self.schema_streak = 0

def should_report(self, flake_guard: int = N_CONSECUTIVE_FLAKE_GUARD) -> bool:
return self.network_streak >= flake_guard or self.schema_streak >= flake_guard


def _run_gh_with_body_file(args: list[str], body: str) -> subprocess.CompletedProcess[str]:
Comment thread
Trecek marked this conversation as resolved.
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
f.write(body)
body_path = f.name
try:
return run_gh([*args, "--body-file", body_path])
finally:
try:
os.unlink(body_path)
except OSError:
logger.debug("canary_body_file_unlink_failed", path=body_path)


class CanaryIssueUpdater:
def __init__(self, *, owner: str, repo: str) -> None:
self._owner = owner
self._repo = repo

def ensure_issue(self, state: CanaryState, title: str, body: str) -> int:
existing = self._find_existing(title)
if existing is not None:
result = _run_gh_with_body_file(
[
"issue",
"edit",
str(existing),
"--repo",
f"{self._owner}/{self._repo}",
],
body,
)
if result.returncode != 0:
logger.warning(
"canary_issue_edit_failed",
issue=existing,
stderr=result.stderr,
)
state.last_issue_number = existing
return existing
result = _run_gh_with_body_file(
[
"issue",
"create",
"--repo",
f"{self._owner}/{self._repo}",
"--title",
title,
"--json",
"number",
],
body,
)
if result.returncode != 0:
msg = f"gh issue create failed: {result.stderr}"
raise RuntimeError(msg)
try:
issue_number = json.loads(result.stdout)["number"]
except (json.JSONDecodeError, KeyError) as exc:
msg = f"gh issue create returned unexpected output: {result.stdout!r}"
raise RuntimeError(msg) from exc
state.last_issue_number = issue_number
return issue_number

def _find_existing(self, title: str) -> int | None:
result = run_gh(
[
"issue",
"list",
"--repo",
f"{self._owner}/{self._repo}",
"--search",
title,
"--state",
"open",
"--json",
"number,title",
"--limit",
"10",
],
)
if result.returncode != 0:
Comment thread
Trecek marked this conversation as resolved.
logger.warning(
"canary_find_existing_failed",
returncode=result.returncode,
stderr=result.stderr,
)
return None
try:
issues = json.loads(result.stdout)
except json.JSONDecodeError:
return None
if not isinstance(issues, list):
return None
for issue in issues:
if issue.get("title") == title:
number = issue.get("number")
if isinstance(number, int):
return number
return 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.

Observation from local review round 0:

[warning] bugs: GitHub --search performs full-text search, not exact-title matching. With --limit 10, a true title match beyond the 10th result would be missed. Consider narrowing with an in:title qualifier (e.g. f'"{title}" in:title') to improve recall within the result window.

Evidence: Code already filters by exact title match at L158 (issue.get('title') == title). The --limit 10 truncation risk is real but adding in:title changes search semantics. Design decision required on acceptable precision vs. current behavior.

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.

Valid observation — flagged for design decision. Code already filters by exact title match at L155 (issue.get('title') == title). The --limit 10 truncation risk is real but adding 'in:title' changes search semantics. Design decision required on acceptable search precision vs. current behavior.

1 change: 1 addition & 0 deletions src/autoskillit/execution/backends/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ IL-1 backend abstraction layer — concrete `CodingAgentBackend` implementations
| `__init__.py` | `BACKEND_REGISTRY`, `get_backend()` factory, re-exports |
| `_backend_cmd_builder_base.py` | `BackendCmdBuilderBase` ABC (frozen dataclass), `FlagVocabulary` NamedTuple, `SHARED_BASELINE_ENV` — canonical location for shared env-assembly keys |
| `_composite_locator.py` | `CompositeSessionLocator` — single dispatch point iterating BACKEND_REGISTRY for session location |
| `_probe_cache.py` | `ProbeResult`, `PROBE_CACHE_TTL`, `read_probe_cache`, `write_probe_cache` — versioned probe result cache |
| `claude.py` | `ClaudeCodeBackend` (incl. `validate_session_layout`), `ClaudeEnvPolicy`, `ClaudeSessionLocator`, `ClaudeStreamParser`, `ClaudeResultParser` (prompt utilities moved to `_claude_prompt.py`) |
| `_claude_prompt.py` | Prompt injection utilities, session constants (`_ensure_skill_prefix`, `_inject_completion_directive`, `_compose_resume_prompt`, etc.), shared by claude + codex + commands |
| `codex.py` | `CodexFlags`, `CodexBackend` (incl. `validate_session_layout`, `setup_session_dir` agent TOML generation via `_generate_agent_tomls`), `CodexEnvPolicy`, `CodexSessionLocator` (parse/config moved to `_codex_parse.py` / `_codex_config.py`) |
Expand Down
63 changes: 63 additions & 0 deletions src/autoskillit/execution/backends/_probe_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Versioned probe result cache with 24-hour TTL.

IL-1 module: imports only stdlib and `autoskillit.core`. Stores a per-cli-version
`ProbeResult` keyed by `cli_version`, surviving across runs while staying
under `PROBE_CACHE_TTL`.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path

from autoskillit.core import get_logger, read_versioned_json, write_versioned_json

logger = get_logger(__name__)

PROBE_CACHE_TTL: timedelta = timedelta(hours=24)
_SCHEMA_VERSION: int = 1


@dataclass(frozen=True, slots=True)
class ProbeResult:
cli_version: str
passed: bool
failure_detail: str | None
probe_timestamp: str


def read_probe_cache(cache_path: Path, cli_version: str) -> ProbeResult | None:
raw = read_versioned_json(cache_path, _SCHEMA_VERSION, logger=logger)
if raw is None:
return None
entries: dict[str, dict] = raw.get("entries", {})
entry = entries.get(cli_version)
if entry is None:
return None
try:
ts = datetime.fromisoformat(entry["probe_timestamp"])
Comment thread
Trecek marked this conversation as resolved.
if (datetime.now(UTC) - ts) > PROBE_CACHE_TTL:
return None
except (KeyError, ValueError, TypeError):
return None
return ProbeResult(
cli_version=cli_version,
passed=entry.get("passed", False),
failure_detail=entry.get("failure_detail"),
probe_timestamp=entry["probe_timestamp"],
)


def write_probe_cache(cache_path: Path, result: ProbeResult) -> None:
try:
existing = read_versioned_json(cache_path, _SCHEMA_VERSION, logger=logger)
entries: dict[str, dict] = (existing or {}).get("entries", {})
entries[result.cli_version] = {
"passed": result.passed,
"failure_detail": result.failure_detail,
"probe_timestamp": result.probe_timestamp,
}
write_versioned_json(cache_path, {"entries": entries}, _SCHEMA_VERSION)
except OSError:
logger.debug("probe_cache_write_failed", path=str(cache_path), exc_info=True)
5 changes: 4 additions & 1 deletion tests/_test_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ class ImportContext(enum.StrEnum):
}

MODULE_CASCADE_CORE: dict[str, frozenset[str]] = {
"_cmd_runner": frozenset({"cli", "core", "recipe"}),
"_cmd_runner": frozenset({"cli", "core", "recipe", "_probe_canary"}),
"_json": frozenset({"core", "execution", "pipeline", "recipe", "server"}),
"feature_flags": frozenset(
{"core", "cli", "config", "execution", "recipe", "server", "workspace"}
Expand Down Expand Up @@ -605,6 +605,7 @@ class ImportContext(enum.StrEnum):
"hook_registry",
"planner",
"smoke_utils",
"_probe_canary",
}
),
# L1
Expand Down Expand Up @@ -896,6 +897,7 @@ class ImportContext(enum.StrEnum):
"_test_filter": frozenset({"arch", "infra", "contracts"}),
"smoke_utils": frozenset({"test_smoke_utils.py", "recipe", "smoke_utils"}),
"version": frozenset({"test_version.py", "server", "cli"}),
"_probe_canary": frozenset({"core", "execution/backends/test_probe_canary.py"}),
}

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -979,6 +981,7 @@ class ImportContext(enum.StrEnum):
"version": frozenset({"test_version.py"}),
"_test_filter": frozenset({"arch", "contracts"}),
"report": frozenset({"report"}),
"_probe_canary": frozenset({"core", "execution/backends/test_probe_canary.py"}),
}

# ---------------------------------------------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion tests/arch/test_subpackage_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def _get_call_func_name(node: ast.Call) -> str | None:
"validator", # recipe/validator.py: defensive exemption for decorator-based rule registry
"settings", # config/settings.py: _CONFIG_SCHEMA = _build_config_schema()
"_headless_path_tokens", # execution/_headless_path_tokens.py: _OUTPUT_PATH_TOKENS
"_probe_cache", # execution/backends/_probe_cache.py: PROBE_CACHE_TTL = timedelta(...)
# _STABLE_DISMISS_WINDOW = timedelta(days=7), _DEV_DISMISS_WINDOW = timedelta(hours=12)
"_install_info", # cli/_install_info.py: window constants (see comment above)
# KITCHEN_GUARDED_COMMANDS: frozenset[str]
Expand Down Expand Up @@ -881,7 +882,7 @@ def test_no_subpackage_exceeds_10_files() -> None:
"recipe/rules": 51, # +commit_guard_regression_route +rules_model +rules_gitignored_deliverable # noqa: E501
"server/tools": 27, # +_preflight.py (dispatch-feasibility preflight)
"hooks/guards": 32, # +fleet_claim_guard, +reset_resume_gate, +recipe_read_guard
"execution/backends": 11, # +_composite_locator.py (CompositeSessionLocator)
"execution/backends": 12, # +_composite_locator.py, +_probe_cache.py
}
violations: list[str] = []
dirs_to_check: list[Path] = []
Expand Down
1 change: 1 addition & 0 deletions tests/contracts/test_package_gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ def test_root_module_allowlist() -> None:
"__init__.py",
"__main__.py",
"_llm_triage.py",
"_probe_canary.py",
"_test_filter.py",
"hook_registry.py",
"version.py",
Expand Down
Loading
Loading