Skip to content

Commit e9f153c

Browse files
Trecekclaude
andauthored
Implementation Plan: T5-P5-A11-WP2 — CI-Facing Shell Layer for Probe Canary State Machine (#4142)
## Summary Create two shell scripts (`scripts/post-probe-failure.sh` and `scripts/create-probe-canary-issue.sh`) and a Python CLI entry point in `_probe_canary.py` that connects them. The shell scripts format CI probe failure data and route it into the existing Python canary state machine — no state-transition logic is reimplemented in shell. The `post-probe-failure.sh` script accepts positional args, validates `.venv/bin/python`, and delegates to `python -m autoskillit._probe_canary post-failure`. The `create-probe-canary-issue.sh` script validates `GITHUB_REPOSITORY` and `GITHUB_TOKEN`, then calls `gh issue create --label autoskillit-canary`. Closes #4030 ## Implementation Plan Plan file: `/home/talon/projects/autoskillit-runs/impl-20260628-071500-126668/.autoskillit/temp/make-plan/t5-p5-a11-wp2-ci-shell-layer_plan_2026-06-28_071500.md` 🤖 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 | 3.4k | 25.1k | 1.7M | 102.4k | 54 | 84.0k | 16m 25s | | verify* | sonnet | 1 | 840 | 10.7k | 346.5k | 59.6k | 24 | 41.1k | 5m 15s | | implement* | MiniMax-M3 | 1 | 117.2k | 13.2k | 1.6M | 0 | 89 | 0 | 7m 52s | | audit_impl* | sonnet | 1 | 2.1k | 11.1k | 229.1k | 60.6k | 17 | 44.6k | 5m 19s | | prepare_pr* | MiniMax-M3 | 1 | 42.3k | 1.7k | 111.6k | 0 | 11 | 0 | 1m 26s | | compose_pr* | MiniMax-M3 | 1 | 37.4k | 1.6k | 175.8k | 0 | 13 | 0 | 47s | | **Total** | | | 203.4k | 63.3k | 4.2M | 102.4k | | 169.7k | 37m 5s | \* *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 | 384 | 4295.7 | 0.0 | 34.3 | | audit_impl | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | **Total** | **384** | 11053.8 | 441.9 | 164.9 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | opus[1m] | 1 | 3.4k | 25.1k | 1.7M | 84.0k | 16m 25s | | sonnet | 2 | 3.0k | 21.7k | 575.6k | 85.7k | 10m 35s | | MiniMax-M3 | 3 | 197.0k | 16.5k | 1.9M | 0 | 10m 5s | --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b5d9b0b commit e9f153c

7 files changed

Lines changed: 400 additions & 0 deletions

File tree

.autoskillit/test-filter-manifest.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,12 @@ install.sh:
201201
scripts/verify-test-filter.sh:
202202
- infra/
203203

204+
scripts/post-probe-failure.sh:
205+
- infra/
206+
207+
scripts/create-probe-canary-issue.sh:
208+
- infra/
209+
204210
# Python scripts under scripts/ — route to verified test consumers
205211
scripts/benchmark-testmon.py:
206212
- infra/
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env bash
2+
# Create a GitHub issue with the autoskillit-canary label.
3+
#
4+
# Usage: scripts/create-probe-canary-issue.sh TITLE BODY
5+
#
6+
# Args: $1=TITLE $2=BODY
7+
#
8+
# Environment:
9+
# GITHUB_REPOSITORY — required, owner/repo format
10+
# GITHUB_TOKEN — required, GitHub authentication token
11+
12+
set -euo pipefail
13+
export LC_ALL=C
14+
15+
TITLE="${1:?Usage: $0 TITLE BODY}"
16+
BODY="${2:?Usage: $0 TITLE BODY}"
17+
18+
if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then
19+
echo "ERROR: GITHUB_REPOSITORY is not set." >&2
20+
exit 1
21+
fi
22+
23+
if [[ -z "${GITHUB_TOKEN:-}" ]]; then
24+
echo "ERROR: GITHUB_TOKEN is not set." >&2
25+
exit 1
26+
fi
27+
28+
gh issue create \
29+
--repo "${GITHUB_REPOSITORY}" \
30+
--title "${TITLE}" \
31+
--label "autoskillit-canary" \
32+
--body "${BODY}"

scripts/post-probe-failure.sh

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env bash
2+
# Route a probe failure record to the Python canary state machine.
3+
#
4+
# Usage: scripts/post-probe-failure.sh BACKEND CLI_VERSION FAILURE_TYPE WORKFLOW_RUN_URL
5+
#
6+
# Args: $1=BACKEND $2=CLI_VERSION $3=FAILURE_TYPE $4=WORKFLOW_RUN_URL
7+
#
8+
# Environment:
9+
# CANARY_STATE_FILE — path to canary state JSON (required)
10+
# GITHUB_REPOSITORY — owner/repo for issue creation (required by Python)
11+
12+
set -euo pipefail
13+
export LC_ALL=C
14+
15+
BACKEND="${1:?Usage: $0 BACKEND CLI_VERSION FAILURE_TYPE WORKFLOW_RUN_URL}"
16+
CLI_VERSION="${2:?Usage: $0 BACKEND CLI_VERSION FAILURE_TYPE WORKFLOW_RUN_URL}"
17+
FAILURE_TYPE="${3:?Usage: $0 BACKEND CLI_VERSION FAILURE_TYPE WORKFLOW_RUN_URL}"
18+
WORKFLOW_RUN_URL="${4:?Usage: $0 BACKEND CLI_VERSION FAILURE_TYPE WORKFLOW_RUN_URL}"
19+
20+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
22+
23+
PYTHON="${PROJECT_ROOT}/.venv/bin/python"
24+
if [[ ! -x "${PYTHON}" ]]; then
25+
echo "ERROR: ${PYTHON} not found. Run 'task install-worktree' first." >&2
26+
exit 1
27+
fi
28+
29+
"${PYTHON}" -m autoskillit._probe_canary post-failure \
30+
--state-file "${CANARY_STATE_FILE:?CANARY_STATE_FILE is not set}" \
31+
--backend "${BACKEND}" \
32+
--cli-version "${CLI_VERSION}" \
33+
--failure-type "${FAILURE_TYPE}" \
34+
--workflow-run-url "${WORKFLOW_RUN_URL}"

src/autoskillit/_probe_canary.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,90 @@ def _find_existing(self, title: str) -> int | None:
165165
if isinstance(number, int):
166166
return number
167167
return None
168+
169+
170+
def _handle_post_failure(
171+
*,
172+
state_file: str,
173+
backend: str,
174+
cli_version: str,
175+
failure_type: str,
176+
workflow_run_url: str,
177+
) -> int:
178+
repo_slug = os.environ.get("GITHUB_REPOSITORY", "")
179+
if not repo_slug or "/" not in repo_slug:
180+
logger.error("post_failure_missing_github_repository")
181+
return 1
182+
183+
owner, repo = repo_slug.split("/", 1)
184+
state_path = Path(state_file)
185+
state = CanaryState.load(state_path)
186+
try:
187+
kind = ErrorKind(failure_type)
188+
except ValueError:
189+
logger.error("post_failure_invalid_failure_type", failure_type=failure_type)
190+
return 1
191+
state.record_failure(kind)
192+
193+
if state.should_report():
194+
title = f"[Canary] {backend} probe failure: {kind.value}"
195+
body = (
196+
f"**Backend:** {backend}\n"
197+
f"**CLI Version:** {cli_version}\n"
198+
f"**Failure Type:** {kind.value}\n"
199+
f"**Workflow Run:** {workflow_run_url}\n"
200+
f"**Network Streak:** {state.network_streak}\n"
201+
f"**Schema Streak:** {state.schema_streak}\n"
202+
)
203+
updater = CanaryIssueUpdater(owner=owner, repo=repo)
204+
try:
205+
updater.ensure_issue(state, title, body)
206+
except Exception as exc:
207+
logger.error("canary_ensure_issue_failed", error=str(exc))
208+
209+
state.save(state_path)
210+
return 0
211+
212+
213+
def _cli_main(argv: list[str] | None = None) -> int:
214+
import argparse
215+
216+
parser = argparse.ArgumentParser(prog="autoskillit._probe_canary")
217+
sub = parser.add_subparsers(dest="command")
218+
219+
post = sub.add_parser(
220+
"post-failure", help="Record a probe failure and optionally create an issue"
221+
)
222+
post.add_argument("--state-file", required=True, help="Path to canary state JSON file")
223+
post.add_argument("--backend", required=True, help="Probe backend identifier")
224+
post.add_argument("--cli-version", required=True, help="CLI version that ran the probe")
225+
post.add_argument(
226+
"--failure-type",
227+
required=True,
228+
choices=[e.value for e in ErrorKind],
229+
help="Error kind: network or schema",
230+
)
231+
post.add_argument("--workflow-run-url", required=True, help="GitHub Actions workflow run URL")
232+
233+
args = parser.parse_args(argv)
234+
235+
if args.command is None:
236+
parser.print_help()
237+
return 1
238+
239+
if args.command == "post-failure":
240+
return _handle_post_failure(
241+
state_file=args.state_file,
242+
backend=args.backend,
243+
cli_version=args.cli_version,
244+
failure_type=args.failure_type,
245+
workflow_run_url=args.workflow_run_url,
246+
)
247+
248+
return 1
249+
250+
251+
if __name__ == "__main__":
252+
import sys
253+
254+
sys.exit(_cli_main())

tests/execution/backends/test_probe_canary.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
CanaryIssueUpdater,
1313
CanaryState,
1414
ErrorKind,
15+
_cli_main,
1516
)
1617

1718
pytestmark = [pytest.mark.layer("execution"), pytest.mark.small]
@@ -202,3 +203,158 @@ def mock_run_gh(args, **kwargs):
202203
assert num == 42
203204
assert state.last_issue_number == 42
204205
assert any(e.get("event") == "canary_issue_edit_failed" for e in cap_logs)
206+
207+
208+
class TestCliMain:
209+
def test_post_failure_records_and_saves(
210+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
211+
) -> None:
212+
"""post-failure writes updated state to the state file."""
213+
monkeypatch.setenv("GITHUB_REPOSITORY", "test-org/test-repo")
214+
state_path = tmp_path / "state.json"
215+
216+
gh_call_count = 0
217+
218+
def mock_run_gh(args, **kwargs):
219+
nonlocal gh_call_count
220+
gh_call_count += 1
221+
return CompletedProcess(args=args, returncode=1, stdout="", stderr="")
222+
223+
monkeypatch.setattr("autoskillit._probe_canary.run_gh", mock_run_gh)
224+
225+
rc = _cli_main(
226+
[
227+
"post-failure",
228+
"--state-file",
229+
str(state_path),
230+
"--backend",
231+
"claude-code",
232+
"--cli-version",
233+
"1.0.0",
234+
"--failure-type",
235+
"network",
236+
"--workflow-run-url",
237+
"https://example.com/run/1",
238+
]
239+
)
240+
assert rc == 0
241+
assert state_path.exists()
242+
raw = json.loads(state_path.read_text())
243+
assert raw["network_streak"] == 1
244+
assert gh_call_count == 0, "run_gh must not be called when streak is below threshold"
245+
246+
def test_post_failure_threshold_triggers_issue_creation(
247+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
248+
) -> None:
249+
"""When streak reaches N_CONSECUTIVE_FLAKE_GUARD, ensure_issue is called."""
250+
monkeypatch.setenv("GITHUB_REPOSITORY", "test-org/test-repo")
251+
state_path = tmp_path / "state.json"
252+
state_path.write_text(
253+
json.dumps(
254+
{
255+
"network_streak": N_CONSECUTIVE_FLAKE_GUARD - 1,
256+
"schema_streak": 0,
257+
"last_issue_number": None,
258+
}
259+
)
260+
)
261+
262+
gh_calls: list[list[str]] = []
263+
264+
def mock_run_gh(args, **kwargs):
265+
gh_calls.append(list(args))
266+
if args[0:2] == ["issue", "list"]:
267+
return CompletedProcess(args=args, returncode=0, stdout="[]", stderr="")
268+
if args[0:2] == ["issue", "create"]:
269+
return CompletedProcess(
270+
args=args,
271+
returncode=0,
272+
stdout=json.dumps({"number": 7}),
273+
stderr="",
274+
)
275+
return CompletedProcess(args=args, returncode=1, stdout="", stderr="")
276+
277+
monkeypatch.setattr("autoskillit._probe_canary.run_gh", mock_run_gh)
278+
279+
rc = _cli_main(
280+
[
281+
"post-failure",
282+
"--state-file",
283+
str(state_path),
284+
"--backend",
285+
"claude-code",
286+
"--cli-version",
287+
"1.0.0",
288+
"--failure-type",
289+
"network",
290+
"--workflow-run-url",
291+
"https://example.com/run/2",
292+
]
293+
)
294+
assert rc == 0
295+
assert any(call[0:2] == ["issue", "list"] for call in gh_calls)
296+
assert any(call[0:2] == ["issue", "create"] for call in gh_calls)
297+
saved = json.loads(state_path.read_text())
298+
assert saved["network_streak"] == N_CONSECUTIVE_FLAKE_GUARD
299+
assert saved["last_issue_number"] == 7
300+
301+
def test_post_failure_below_threshold_no_issue(
302+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
303+
) -> None:
304+
"""Below threshold, no gh issue commands are issued."""
305+
monkeypatch.setenv("GITHUB_REPOSITORY", "test-org/test-repo")
306+
state_path = tmp_path / "state.json"
307+
308+
def mock_run_gh(args, **kwargs):
309+
raise AssertionError(f"Unexpected gh call: {args}")
310+
311+
monkeypatch.setattr("autoskillit._probe_canary.run_gh", mock_run_gh)
312+
313+
rc = _cli_main(
314+
[
315+
"post-failure",
316+
"--state-file",
317+
str(state_path),
318+
"--backend",
319+
"claude-code",
320+
"--cli-version",
321+
"1.0.0",
322+
"--failure-type",
323+
"network",
324+
"--workflow-run-url",
325+
"https://example.com/run/3",
326+
]
327+
)
328+
assert rc == 0
329+
assert state_path.exists()
330+
raw = json.loads(state_path.read_text())
331+
assert raw["network_streak"] == 1
332+
333+
def test_post_failure_missing_github_repository(
334+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
335+
) -> None:
336+
"""Missing GITHUB_REPOSITORY env var returns 1."""
337+
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
338+
state_path = tmp_path / "state.json"
339+
340+
rc = _cli_main(
341+
[
342+
"post-failure",
343+
"--state-file",
344+
str(state_path),
345+
"--backend",
346+
"claude-code",
347+
"--cli-version",
348+
"1.0.0",
349+
"--failure-type",
350+
"network",
351+
"--workflow-run-url",
352+
"https://example.com/run/4",
353+
]
354+
)
355+
assert rc == 1
356+
357+
def test_cli_no_command_returns_nonzero(self) -> None:
358+
"""Calling _cli_main with no subcommand returns non-zero."""
359+
rc = _cli_main([])
360+
assert rc != 0

tests/infra/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ CI/CD configuration, security, guard coverage, and release sanity tests.
4848
| `test_pretty_output_hook_infra.py` | Tests: pretty_output hook infrastructure, fail-open, and coverage contracts |
4949
| `test_pretty_output_integration.py` | End-to-end schema consistency tests for the pretty_output hook |
5050
| `test_pretty_output_recipe.py` | Tests: pretty_output token/timing, load_recipe, list_recipes, open_kitchen, deduplication |
51+
| `test_probe_scripts.py` | Tests for CI-facing probe canary shell scripts (post-probe-failure.sh, create-probe-canary-issue.sh) — syntax, executable bit, and env-var validation |
5152
| `test_pyproject_bounds.py` | Tests for pyproject.toml version lower bounds |
5253
| `test_recipe_read_guard.py` | Tests for the recipe_read_guard PreToolUse hook — blocks recipe/skill/agent file reads |
5354
| `test_pyproject_metadata.py` | Verify pyproject.toml contains required public release metadata |

0 commit comments

Comments
 (0)