From 4894ebf56e986781037086c16ff00b52dcfb81af Mon Sep 17 00:00:00 2001 From: root Date: Wed, 8 Jul 2026 21:26:05 +0300 Subject: [PATCH] feat(maf): consolidate refresh stack and workflow hardening --- .github/workflows/ci.yml | 142 +++++++--------- .github/workflows/codeql.yml | 8 +- .github/workflows/pages.yml | 8 +- .github/workflows/pr-lint.yml | 2 +- .github/workflows/stale.yml | 2 +- README.md | 22 +++ docs/wiki/Architecture.md | 80 +++++++++ docs/wiki/Decisions.md | 40 +++++ docs/wiki/Home.md | 40 +++++ docs/wiki/Operations.md | 50 ++++++ maf_starter/critic.py | 157 ++++++++++++++++++ maf_starter/critic_cli.py | 46 ++++++ maf_starter/provider_fallback.py | 61 ++++++- maf_starter/telemetry.py | 19 +++ maf_starter/worker_boundary.py | 3 + tests/test_critic.py | 189 ++++++++++++++++++++++ tests/test_provider_fallback_telemetry.py | 179 ++++++++++++++++++++ tests/test_worker_boundary.py | 15 +- 18 files changed, 963 insertions(+), 100 deletions(-) create mode 100644 docs/wiki/Architecture.md create mode 100644 docs/wiki/Decisions.md create mode 100644 docs/wiki/Home.md create mode 100644 docs/wiki/Operations.md create mode 100644 maf_starter/critic.py create mode 100644 maf_starter/critic_cli.py create mode 100644 maf_starter/telemetry.py create mode 100644 tests/test_critic.py create mode 100644 tests/test_provider_fallback_telemetry.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3faaa2e..c2c5cd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,86 +1,56 @@ -name: CI - -on: - push: - branches: [ main ] - pull_request: - -permissions: - contents: read - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - name: Python ${{ matrix.python-version }} / ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - python-version: ['3.12'] - - steps: - - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - cache: pip - - - name: Install dependencies - run: python -m pip install -r requirements.txt - - - name: Verify dependency consistency - run: python -m pip check - - - name: Contract compatibility (pinned cas-contracts v1.1) - # Consumer-side gate: fails red if autogen's pinned CAS contract version - # or the vendored v1.1 schema release drifts. See - # tests/test_contract_compatibility.py for the validated contract surface. - run: python -m pytest tests/test_contract_compatibility.py -q --tb=short - - - name: Run full test suite - run: | - python -m pytest -q --tb=short --cov --cov-branch --cov-report=xml --cov-fail-under=73 - - - name: Emit branch coverage telemetry - if: always() - shell: python - run: | - import json - import sys - import xml.etree.ElementTree as ET - from pathlib import Path - - required = 53.5 - coverage_file = Path("coverage.xml") - if not coverage_file.exists(): - print(json.dumps({ - "event": "ci_failure", - "metric": "branch-rate", - "coverage_percent": None, - "required": required, - "reason": "coverage.xml missing", - })) - sys.exit(1) - - branch_rate = float(ET.parse(coverage_file).getroot().attrib["branch-rate"]) * 100.0 - passed = branch_rate >= required - print(json.dumps({ - "event": "ci_pass" if passed else "ci_failure", - "metric": "branch-rate", - "coverage_percent": round(branch_rate, 2), - "required": required, - })) - sys.exit(0 if passed else 1) - - - name: Compile Python sources - run: python -m compileall autogen_starter autogen_dashboard maf_starter main.py -q - - - name: Validate dashboard JavaScript - run: node --check autogen_dashboard/static/app.js +name: CI + +on: + push: + branches: [ main ] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Python ${{ matrix.python-version }} / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ['3.12'] + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: python -m pip install -r requirements.txt + + - name: Verify dependency consistency + run: python -m pip check + + - name: Contract compatibility (pinned cas-contracts v1.1) + # Consumer-side gate: fails red if autogen's pinned CAS contract version + # or the vendored v1.1 schema release drifts. See + # tests/test_contract_compatibility.py for the validated contract surface. + run: python -m pytest tests/test_contract_compatibility.py -q --tb=short + + - name: Run full test suite + run: | + python -m pip install pytest-cov + python -m pytest -q --tb=short --cov=. --cov-report=xml + + - name: Compile Python sources + run: python -m compileall autogen_starter autogen_dashboard maf_starter main.py -q + + - name: Validate dashboard JavaScript + run: node --check autogen_dashboard/static/app.js diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 157cc00..5824f1a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,12 +19,12 @@ jobs: language: [ 'javascript', 'python' ] steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c562f18..f904732 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -14,13 +14,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: 3.x - run: pip install mkdocs-material - run: mkdocs build - - uses: actions/upload-pages-artifact@v5 + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: path: ./site deploy: @@ -36,4 +36,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index ebc13b5..c515ec8 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -13,6 +13,6 @@ jobs: permissions: pull-requests: read steps: - - uses: amannn/action-semantic-pull-request@v6 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 84a62fa..67a151a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,7 +11,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@v10 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10 with: days-before-stale: 60 days-before-close: 7 diff --git a/README.md b/README.md index 65ca9db..6fb4240 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,16 @@ This repo already carries more engineering evidence than the old README surfaced - `tests/test_phase5_ui_contract.py` and `tests/test_phase5_operator_views.py` lock the operator UI to timeline, routing, artifact, and specialist-view contracts. - `.github/workflows/ci.yml` installs the declared environment and runs the full suite, Python compilation, dependency consistency, and JavaScript syntax checks on Windows and Linux. +### Test Coverage + +`main`'s `Run full test suite` CI step installs `pytest-cov` and runs `pytest --cov=. --cov-report=xml`, +but does not currently fail the build on a coverage threshold. A ratcheted coverage gate is +**in progress (PR #11)**: it adds a `.coveragerc` with `branch = true` and a `--cov-fail-under` +threshold, plus branch-coverage telemetry read from the produced `coverage.xml`. Per the PR's +own recorded validation, the repo-local suite reached ~73.3% total coverage and ~54.6% branch +coverage against the ratcheted threshold. Until PR #11 merges, coverage is measured but not +enforced on `main`. + ## Quickstart The checked-in snapshot supports a clean-clone local dashboard and full validation workflow: @@ -69,6 +79,16 @@ Copy-Item .env.example .env .\.venv\Scripts\python.exe main.py dashboard --host 127.0.0.1 --port 8000 ``` +Do not install into the system Python on WSL/Linux. That interpreter may be externally managed +under PEP 668, which blocks direct `pip install` runs and creates ambiguous test environments. +Always use the repo-local `.venv` so verification and coverage come from `requirements.txt`, not +ambient machine packages. + +MAF 1.0 direction: the current operator shell still uses the local dashboard, but the +orchestration core is intentionally aligned with Microsoft Agent Framework's graph-style +workflow model. The next durable UI/runtime step is to expose the existing manager-led flow +through DevUI or AG-UI style surfaces rather than inventing a separate orchestration concept. + Run the complete regression suite before changing runtime behavior: ```powershell @@ -135,3 +155,5 @@ That is the right foundation for a later Azure-hosted control plane or worker bo ## License MIT -- see [LICENSE](LICENSE) + + diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md new file mode 100644 index 0000000..598931c --- /dev/null +++ b/docs/wiki/Architecture.md @@ -0,0 +1,80 @@ +# Architecture + +## Worker fan-out + telemetry boundary + critic gate + +```mermaid +flowchart TD + subgraph Manager["Manager-led workflow (entities/repo_team/workflow.py)"] + Plan[Planner
gemini-2.5-pro] --> Research[Researcher
gemini-2.5-flash] + Research --> Impl[Implementer
gemini-2.5-pro] + Impl --> Review[Reviewer
gemini-2.5-pro] + end + subgraph Boundary["Worker boundary (maf_starter/worker_boundary.py)"] + WB[WorkerBoundary
async dispatch, run_id, status polling] + end + subgraph Telemetry["Telemetry (in progress, PR #12)"] + T[emit_failure_telemetry
structured JSON on stderr] + end + subgraph Critic["Peer critic gate (in progress, PR #14)"] + C[Deterministic pattern-scan
engine] + end + subgraph Fallback["Provider fallback (maf_starter/provider_fallback.py)"] + F[Gemini -> Anthropic -> local CLI] + end + Manager --> Boundary + Impl --> Fallback + Fallback -.->|on failure| Telemetry + Review --> Critic + Critic -.->|gates| Manager +``` + + + +## Worker fan-out (landed on `main`) + +`entities/repo_team/workflow.py` wires the canonical `planning -> research -> implementation -> +review -> validation` sequence via `agent_framework_orchestrations.SequentialBuilder`. Each +specialist is built in `maf_starter/team_factory.py` with a distinct model tier. Long-running +executions are dispatched through `WorkerBoundary` (`maf_starter/worker_boundary.py`), which +returns a `run_id` immediately and exposes `pending` / `running` / `done` / `error:` +status polling instead of blocking HTTP ingress on the full execution path. + +This matches the direction Microsoft is now pushing more explicitly in Agent Framework 1.0: +workflow-native orchestration with richer graphical operator surfaces instead of a single opaque +chat loop. Today this repo uses a sequential builder plus a custom dashboard. The intended next +step is to keep the current specialist topology but surface it through first-party MAF UI +primitives such as DevUI or AG-UI endpoints when the repo is ready. + +## Telemetry boundary — in progress (PR #12) + +`main` today does not have a `maf_starter/telemetry.py` module. PR #12 +(`feat(28-02): structured JSON failure telemetry + CLI fallback size guards`) adds +`emit_failure_telemetry(event, **fields)` — a stdlib-only, never-raising function that writes +one flushed JSON line to stderr — wired into `provider_fallback.py`'s fallback middleware at +`provider_failed`, `fallback_step_failed`, `fallback_succeeded`, and `fallback_exhausted` +points, plus a 1MB CLI output/prompt size guard. Until merged, provider-fallback failures are +still caught (existing `except Exception` boundaries in `provider_fallback.py` and +`worker_boundary.py`) but not emitted as structured telemetry. + +## Critic gate — in progress (PR #14) + +No critic module exists on `main` yet. PR #14 +(`feat(29-01): deterministic peer critic pattern-scan engine`) introduces a deterministic +pattern-scan reviewer intended to sit after the Reviewer specialist as an additional gate. +Until merged, review is limited to the LLM-based Reviewer agent's own assessment. + +## Provider routing and fallback (landed on `main`) + +`maf_starter/routing_policy.py` selects a model tier by task depth; `maf_starter/ +provider_fallback.py` retries across the Gemini API, optional Anthropic API, and local CLI +providers (`gemini.cmd`, `claude`, `codex.cmd`) on heuristic quota/rate-limit errors, recording +route-attempt history. + +## Approvals and bounded repo tools (landed on `main`) + +`maf_starter/tools.py` enforces repo-root path boundaries and blocks writes to sensitive +targets (e.g. `.env`). `maf_starter/approval_policy.py` classifies file operations and +validation commands so destructive or externally visible actions pause for operator approval +via the dashboard's approval surface. + + diff --git a/docs/wiki/Decisions.md b/docs/wiki/Decisions.md new file mode 100644 index 0000000..8a4394a --- /dev/null +++ b/docs/wiki/Decisions.md @@ -0,0 +1,40 @@ +# Decisions + +## ADR convention + +`docs/adr/README.md` establishes the convention (sequential numbering, Context/Decision/ +Consequences) but **no numbered ADR files exist in the repo yet**. Decisions to date live in +`.planning/phases/` plan/summary pairs instead. + +## Phase history (`.planning/phases/`, this repo's own GSD project) + +| Phase | Topic | +|---|---| +| 01 | Workspace and durable run foundation | +| 02 | Manager-led orchestration core | +| 03 | Specialist delegation and routing visibility | +| 04 | Autonomous repo execution and validation guardrails | +| 05 | Polished operator workbench | +| 06 | API boundary and control-plane contract | +| 07 | Worker boundary | + +See each `.planning/phases//*-SUMMARY.md` for the detailed record. + +## Open decisions tracked in this Phase 36 refresh + +- **PR #11** (`feat/phase-26-coverage-gates`) — already merged to `main`; it is now the + compatibility baseline that exposed the remaining stale branch stack. +- **PRs #12, #13, #14, #15, #16** — still open on GitHub but stale against current `main`. + Their surviving runtime, CI, and docs changes are being consolidated into one refreshed branch + instead of re-merging the old dependency snapshot piecemeal. + +## Directional decision + +The medium-term architecture should adopt Microsoft Agent Framework's first-party workflow and UI +direction where it fits the local-first product: + +- keep manager-led orchestration as a graph/workflow problem, not a free-form chat problem; +- prefer MAF-native workflow builders and DevUI or AG-UI style surfaces over inventing a parallel UI abstraction; +- keep the current dashboard only as a bridge while the richer graphical workflow surface matures. + + diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 0000000..71f4530 --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,40 @@ +# autogen Wiki + +## Role in the CAS portfolio + +`autogen` is the **Execution plane** of the Coding-Autopilot-System three-plane model (Control +/ Execution / Governance). Built on Microsoft Agent Framework, it runs manager-led, +specialist-delegated engineering work against a real repository: planning, research, +implementation, review, and validation, with bounded repo tools and an approval gate for +destructive actions. + +| Plane | This repo's responsibility | +|---|---| +| Control | *(not this repo — see `gsd-orchestrator`)* | +| Execution | Manager-led worker fan-out, bounded repo tools, provider routing/fallback, run artifacts | +| Governance | *(not this repo — see `Promptimprover`, `cas-contracts`, `cas-evals`)* | + +## Quickstart + +- [README.md](../../README.md) — Quickstart, configuration reference, evidence posture +- [Architecture](./Architecture.md) — worker fan-out, telemetry boundary (in progress), critic gate (in progress) +- [Operations](./Operations.md) — verified run/test/CI commands +- [Decisions](./Decisions.md) — phase history and open PRs + +## MAF 1.0 alignment + +The repo direction is to reuse Microsoft Agent Framework's workflow and UI model, not to fork it. + +- keep the current manager-led specialist graph and evolve it with official MAF workflow builders; +- keep the local operator experience, but converge toward DevUI or AG-UI style surfaces for richer execution traces and graphical workflow visibility; +- treat the legacy dashboard as a transition shell, not the long-term orchestration abstraction. + +## Ecosystem links + +Part of the [Coding-Autopilot-System](https://github.com/Coding-Autopilot-System) org: +[gsd-orchestrator](https://github.com/Coding-Autopilot-System/gsd-orchestrator) (control plane) · +[Promptimprover](https://github.com/Coding-Autopilot-System/Promptimprover) (prompt governance) · +[cas-contracts](https://github.com/Coding-Autopilot-System/cas-contracts) (shared schemas) · +[cas-evals](https://github.com/Coding-Autopilot-System/cas-evals) (evidence gate) + + diff --git a/docs/wiki/Operations.md b/docs/wiki/Operations.md new file mode 100644 index 0000000..c31768f --- /dev/null +++ b/docs/wiki/Operations.md @@ -0,0 +1,50 @@ +# Operations + +## Setup + +```powershell +git clone https://github.com/Coding-Autopilot-System/autogen.git +Set-Location autogen +python -m venv .venv +.\.venv\Scripts\python.exe -m pip install -r requirements.txt +Copy-Item .env.example .env +``` + +If you are running from WSL/Linux, do not install into the system interpreter. This repo expects +an isolated `.venv`; distro Python can be PEP 668 managed and will either reject direct package +installs or produce non-reproducible verification results. + +## Run + +```powershell +.\.venv\Scripts\python.exe main.py providers +.\.venv\Scripts\python.exe main.py dashboard --host 127.0.0.1 --port 8000 +``` + +## Test + +```powershell +.\.venv\Scripts\python.exe -m pytest -q --tb=short +``` + +Contract-compatibility gate only (consumer-side check against the pinned `cas-contracts` v1.1 +release): + +```powershell +.\.venv\Scripts\python.exe -m pytest tests/test_contract_compatibility.py -q --tb=short +``` + +## CI (`.github/workflows/ci.yml`, matrix: ubuntu-latest + windows-latest, Python 3.12, 20-minute timeout) + +1. `actions/checkout` pinned to a full commit SHA +2. `actions/setup-python` pinned to a full commit SHA (Python 3.12, pip cache) +3. `pip install -r requirements.txt` +4. `pip check` — dependency consistency +5. **Contract compatibility** — `tests/test_contract_compatibility.py`, fails red on pinned-contract drift +6. **Run full test suite** — installs `pytest-cov`, runs `pytest --cov=. --cov-report=xml` (coverage is measured; no `--cov-fail-under` threshold is enforced on `main` yet — see [Architecture](./Architecture.md) and PR #11) +7. `python -m compileall autogen_starter autogen_dashboard maf_starter main.py -q` +8. `node --check autogen_dashboard/static/app.js` — legacy dashboard JS syntax check + +A separate `.github/workflows/codeql.yml` runs CodeQL analysis (badge in the root `README.md`). + + diff --git a/maf_starter/critic.py b/maf_starter/critic.py new file mode 100644 index 0000000..a764a95 --- /dev/null +++ b/maf_starter/critic.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +import sys +from typing import Literal + +from maf_starter.config import Settings + + +@dataclass(frozen=True) +class Finding: + check: str + severity: Literal["blocking", "advisory"] + file: str + line: int | None + detail: str + + +@dataclass(frozen=True) +class DiffFile: + path: str + added_lines: list[str] + + +@dataclass(frozen=True) +class CriticReport: + findings: list[Finding] + blocking_count: int + advisory_count: int + exit_code: int + + +_BARE_EXCEPT_PATTERN = re.compile(r"^\s*except\s*:\s*$") +_EXCEPTION_EXCEPT_PATTERN = re.compile(r"^\s*except\s+Exception(?:\s+as\s+\w+)?\s*:\s*$") +_LOG_TOKENS = ("logger.", "logging.", "print(", "emit_failure_telemetry(", "telemetry.") +_TYPED_FAILURE_TOKENS = ("FailureState", "McpException", "ValueError", "RuntimeError", "InvalidOperationException") + + +def parse_unified_diff(diff_text: str) -> list[DiffFile]: + files: list[DiffFile] = [] + current_path: str | None = None + current_added: list[str] = [] + + for line in diff_text.splitlines(): + if line.startswith("diff --git "): + if current_path is not None: + files.append(DiffFile(path=current_path, added_lines=current_added)) + current_path = None + current_added = [] + continue + if line.startswith("+++ b/"): + current_path = line[6:] + continue + if current_path is None: + continue + if line.startswith("+") and not line.startswith("+++"): + current_added.append(line[1:]) + + if current_path is not None: + files.append(DiffFile(path=current_path, added_lines=current_added)) + + return files + + +def check_bare_except(file_path: str, added_lines: list[str]) -> list[Finding]: + findings: list[Finding] = [] + for index, line in enumerate(added_lines): + if not (_BARE_EXCEPT_PATTERN.match(line) or _EXCEPTION_EXCEPT_PATTERN.match(line)): + continue + window = added_lines[index + 1 : index + 5] + has_reraise = any("raise" in candidate for candidate in window) + has_logging = any(any(token in candidate for token in _LOG_TOKENS) for candidate in window) + has_typed_failure = any(any(token in candidate for token in _TYPED_FAILURE_TOKENS) for candidate in window) + if has_reraise and has_logging and has_typed_failure: + continue + findings.append( + Finding( + check="bare-except", + severity="blocking", + file=file_path, + line=index + 1, + detail="Broad exception handler added without a typed, logged re-raise path.", + ) + ) + return findings + + +def check_missing_telemetry(file_path: str, added_lines: list[str]) -> list[Finding]: + findings: list[Finding] = [] + for index, line in enumerate(added_lines): + if "except" not in line: + continue + window = added_lines[index + 1 : index + 5] + has_logging = any(any(token in candidate for token in _LOG_TOKENS) for candidate in window) + if has_logging: + continue + findings.append( + Finding( + check="missing-telemetry", + severity="advisory", + file=file_path, + line=index + 1, + detail="Exception path does not emit structured telemetry or logging.", + ) + ) + return findings + + +def check_file_size_limit(file_path: str, added_line_count: int, limit: int = 400) -> list[Finding]: + if added_line_count <= limit: + return [] + return [ + Finding( + check="file-size-limit", + severity="advisory", + file=file_path, + line=None, + detail=f"Added line count {added_line_count} exceeds limit {limit}.", + ) + ] + + +def run_critic(diff_text: str, *, llm_pass: bool = False, settings: Settings | None = None) -> CriticReport: + if llm_pass: + raise NotImplementedError("LLM critic pass is reserved for a future phase.") + + try: + _ = settings + files = parse_unified_diff(diff_text) + if not files: + raise ValueError("No unified diff content was found.") + + findings: list[Finding] = [] + for diff_file in files: + findings.extend(check_bare_except(diff_file.path, diff_file.added_lines)) + findings.extend(check_missing_telemetry(diff_file.path, diff_file.added_lines)) + findings.extend(check_file_size_limit(diff_file.path, len(diff_file.added_lines))) + + blocking_count = sum(1 for finding in findings if finding.severity == "blocking") + advisory_count = sum(1 for finding in findings if finding.severity == "advisory") + return CriticReport( + findings=findings, + blocking_count=blocking_count, + advisory_count=advisory_count, + exit_code=1 if blocking_count > 0 else 0, + ) + except Exception as exc: # noqa: BLE001 + print(f"critic error: {exc}", file=sys.stderr) + finding = Finding( + check="critic-error", + severity="advisory", + file="", + line=None, + detail=str(exc), + ) + return CriticReport(findings=[finding], blocking_count=0, advisory_count=1, exit_code=0) diff --git a/maf_starter/critic_cli.py b/maf_starter/critic_cli.py new file mode 100644 index 0000000..b984d81 --- /dev/null +++ b/maf_starter/critic_cli.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +from maf_starter.critic import run_critic + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="critic", description="Run the Resilience First peer critic against a unified diff") + parser.add_argument("--diff", required=True, help="Diff file path or '-' to read from stdin") + parser.add_argument( + "--severity-gate", + choices=("blocking", "advisory"), + default="blocking", + help="Exit non-zero on blocking findings only, or on any finding in advisory mode", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + try: + if args.diff == "-": + diff_text = sys.stdin.read() + else: + diff_text = Path(args.diff).read_text(encoding="utf-8", errors="replace") + except (FileNotFoundError, OSError) as exc: + print(f"critic: unable to read diff: {exc}", file=sys.stderr) + return 2 + + report = run_critic(diff_text) + for finding in report.findings: + print(f"{finding.file}:{finding.line or '-'} [{finding.severity}] {finding.check} - {finding.detail}") + print(f"critic: {report.blocking_count} blocking, {report.advisory_count} advisory") + + if args.severity_gate == "advisory": + return 1 if (report.blocking_count + report.advisory_count) > 0 else 0 + return report.exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/maf_starter/provider_fallback.py b/maf_starter/provider_fallback.py index 758b1ec..7d31929 100644 --- a/maf_starter/provider_fallback.py +++ b/maf_starter/provider_fallback.py @@ -68,9 +68,12 @@ async def get_final_response(self): from maf_starter.execution_profile import LOCAL_PROFILE, ExecutionProfile from maf_starter.routing_policy import RoutingPlan, build_routing_plan from maf_starter.routing_types import CapabilityChange, ChainStep, RouteAttempt +from maf_starter.telemetry import emit_failure_telemetry FALLBACK_NOTICE = "[Fallback provider used because the earlier model/provider in the chain failed.]\n" +MAX_CLI_OUTPUT_BYTES = 1_000_000 +MAX_CLI_PROMPT_BYTES = 1_000_000 FALLBACK_ERROR_MARKERS = ( "resource_exhausted", "quota", @@ -132,6 +135,12 @@ async def fallback_middleware(context: ChatContext, call_next): error=last_error, ) ) + emit_failure_telemetry( + "provider_failed", + provider=route.primary_provider, + model=route.primary_model, + error=str(exc), + ) for step in route.fallback_steps: try: context.result = await _execute_chain_step( @@ -143,6 +152,12 @@ async def fallback_middleware(context: ChatContext, call_next): attempt_log=attempt_log, fallback_index=len(attempt_log), ) + emit_failure_telemetry( + "fallback_succeeded", + provider=step.provider, + model=step.model or step.label, + primary_error=str(last_error), + ) return except Exception as fallback_exc: last_error = fallback_exc @@ -156,8 +171,21 @@ async def fallback_middleware(context: ChatContext, call_next): error=fallback_exc, ) ) + emit_failure_telemetry( + "fallback_step_failed", + provider=step.provider, + model=step.model or step.label, + error=str(fallback_exc), + fallback_index=len(attempt_log) - 1, + ) continue + emit_failure_telemetry( + "fallback_exhausted", + primary_provider=route.primary_provider, + attempted_providers=[attempt.provider for attempt in attempt_log], + final_error=str(last_error), + ) raise last_error finally: reset_run_scope(scope_tokens) @@ -233,6 +261,12 @@ async def _stream(): error=last_error, ) ) + emit_failure_telemetry( + "provider_failed", + provider=route.primary_provider, + model=route.primary_model, + error=str(exc), + ) for step in route.fallback_steps: try: fallback_result = await _execute_chain_step( @@ -244,6 +278,12 @@ async def _stream(): attempt_log=attempt_log, fallback_index=len(attempt_log), ) + emit_failure_telemetry( + "fallback_succeeded", + provider=step.provider, + model=step.model or step.label, + primary_error=str(last_error), + ) if isinstance(fallback_result, ResponseStream): async for update in fallback_result: yield update @@ -274,6 +314,19 @@ async def _stream(): error=fallback_exc, ) ) + emit_failure_telemetry( + "fallback_step_failed", + provider=step.provider, + model=step.model or step.label, + error=str(fallback_exc), + fallback_index=len(attempt_log) - 1, + ) + emit_failure_telemetry( + "fallback_exhausted", + primary_provider=route.primary_provider, + attempted_providers=[attempt.provider for attempt in attempt_log], + final_error=str(last_error), + ) raise last_error if isinstance(original_stream, ResponseStream): @@ -791,6 +844,9 @@ def _run_subprocess( raise RuntimeError(f"{provider_name} failed: {detail}") output = completed.stdout.strip() + output_bytes = len(output.encode("utf-8", errors="replace")) + if output_bytes > MAX_CLI_OUTPUT_BYTES: + raise RuntimeError(f"{provider_name} output exceeds {MAX_CLI_OUTPUT_BYTES} bytes") if not output: raise RuntimeError(f"{provider_name} returned empty output") @@ -818,7 +874,10 @@ def _messages_to_prompt(messages: list[Message] | tuple[Message, ...]) -> str: for message in messages: rendered.append(f"{str(message.role).upper()}: {_message_text(message)}") rendered.append("ASSISTANT:") - return "\n\n".join(rendered) + prompt = "\n\n".join(rendered) + if len(prompt.encode("utf-8", errors="replace")) > MAX_CLI_PROMPT_BYTES: + raise ValueError(f"Rendered CLI prompt exceeds {MAX_CLI_PROMPT_BYTES} bytes") + return prompt def _message_text(message: Message) -> str: diff --git a/maf_starter/telemetry.py b/maf_starter/telemetry.py new file mode 100644 index 0000000..32fb740 --- /dev/null +++ b/maf_starter/telemetry.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from typing import Any + + +def emit_failure_telemetry(event: str, **fields: Any) -> None: + payload = { + "event": event, + "timestamp": datetime.now(timezone.utc).isoformat(), + **fields, + } + try: + sys.stderr.write(json.dumps(payload, separators=(",", ":")) + "\n") + sys.stderr.flush() + except Exception: # pragma: no cover - telemetry must never mask the original failure + return diff --git a/maf_starter/worker_boundary.py b/maf_starter/worker_boundary.py index 778bb91..1717618 100644 --- a/maf_starter/worker_boundary.py +++ b/maf_starter/worker_boundary.py @@ -4,6 +4,8 @@ from enum import Enum from typing import Any, Awaitable, Callable +from maf_starter.telemetry import emit_failure_telemetry + class WorkerProfile(str, Enum): LOCAL = "local" @@ -58,6 +60,7 @@ async def _run(self, run_id: str, workflow: Callable[[], Awaitable[Any]]) -> Non self._status[run_id] = "done" except Exception as exc: # noqa: BLE001 self._status[run_id] = f"error:{exc}" + emit_failure_telemetry("worker_task_failed", run_id=run_id, error=str(exc)) def get_status(self, run_id: str) -> _RunStatus | None: """Return the current status string for *run_id*, or None if unknown.""" diff --git a/tests/test_critic.py b/tests/test_critic.py new file mode 100644 index 0000000..0860e8d --- /dev/null +++ b/tests/test_critic.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import io +from pathlib import Path +import textwrap + +import pytest + +from maf_starter.critic import ( + check_bare_except, + check_file_size_limit, + check_missing_telemetry, + parse_unified_diff, + run_critic, +) +from maf_starter.critic_cli import build_parser, main + + +DIRTY_DIFF = textwrap.dedent( + """\ + diff --git a/app.py b/app.py + +++ b/app.py + @@ + +try: + + do_work() + +except Exception: + + pass + """ +) + +DIRTY_DIFF_WITH_TELEMETRY_GAP = textwrap.dedent( + """\ + diff --git a/app.py b/app.py + +++ b/app.py + @@ + +try: + + do_work() + +except ValueError: + + recover() + """ +) + +CLEAN_DIFF = textwrap.dedent( + """\ + diff --git a/app.py b/app.py + +++ b/app.py + @@ + +try: + + do_work() + +except Exception as exc: + + logger.error("failed", exc_info=exc) + + raise RuntimeError("typed failure") from exc + """ +) + + +def test_parse_unified_diff_returns_changed_files_and_added_lines() -> None: + diff_text = textwrap.dedent( + """\ + diff --git a/app.py b/app.py + +++ b/app.py + @@ + +first + diff --git a/worker.py b/worker.py + +++ b/worker.py + @@ + +second + """ + ) + + files = parse_unified_diff(diff_text) + + assert len(files) == 2 + assert files[0].path == "app.py" + assert files[0].added_lines == ["first"] + assert files[1].path == "worker.py" + assert files[1].added_lines == ["second"] + + +def test_check_bare_except_blocks_untyped_unlogged_handler() -> None: + findings = check_bare_except("app.py", ["except Exception:", " pass"]) + + assert len(findings) == 1 + assert findings[0].check == "bare-except" + assert findings[0].severity == "blocking" + + +def test_check_bare_except_allows_logged_typed_reraise() -> None: + findings = check_bare_except( + "app.py", + [ + "except Exception as exc:", + ' logger.error("boom", exc_info=exc)', + ' raise RuntimeError("typed failure") from exc', + ], + ) + + assert findings == [] + + +def test_check_missing_telemetry_returns_advisory_only() -> None: + findings = check_missing_telemetry("app.py", ["except ValueError:", " recover()"]) + + assert len(findings) == 1 + assert findings[0].check == "missing-telemetry" + assert findings[0].severity == "advisory" + + +def test_check_file_size_limit_flags_large_files() -> None: + findings = check_file_size_limit("big.py", 401, limit=400) + + assert len(findings) == 1 + assert findings[0].check == "file-size-limit" + assert findings[0].severity == "advisory" + + +def test_run_critic_blocks_dirty_diff() -> None: + report = run_critic(DIRTY_DIFF) + + assert report.exit_code == 1 + assert report.blocking_count == 1 + assert any(finding.check == "bare-except" for finding in report.findings) + + +def test_run_critic_passes_clean_diff() -> None: + report = run_critic(CLEAN_DIFF) + + assert report.exit_code == 0 + assert report.blocking_count == 0 + assert report.advisory_count == 0 + + +def test_run_critic_returns_advisory_error_on_malformed_input() -> None: + report = run_critic("not a diff at all") + + assert report.exit_code == 0 + assert report.blocking_count == 0 + assert report.advisory_count == 1 + assert report.findings[0].check == "critic-error" + + +def test_build_parser_defaults_to_blocking_gate() -> None: + args = build_parser().parse_args(["--diff", "-"]) + + assert args.diff == "-" + assert args.severity_gate == "blocking" + + +def test_main_returns_zero_for_clean_diff_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + diff_path = tmp_path / "clean.diff" + diff_path.write_text(CLEAN_DIFF, encoding="utf-8") + + exit_code = main(["--diff", str(diff_path)]) + + out = capsys.readouterr().out + assert exit_code == 0 + assert "critic: 0 blocking, 0 advisory" in out + + +def test_main_returns_one_for_blocking_diff_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + diff_path = tmp_path / "dirty.diff" + diff_path.write_text(DIRTY_DIFF, encoding="utf-8") + + exit_code = main(["--diff", str(diff_path)]) + + out = capsys.readouterr().out + assert exit_code == 1 + assert "[blocking] bare-except" in out + + +def test_main_returns_two_for_missing_diff_path(capsys: pytest.CaptureFixture[str]) -> None: + exit_code = main(["--diff", "missing.diff"]) + + err = capsys.readouterr().err + assert exit_code == 2 + assert "unable to read diff" in err + + +def test_main_reads_diff_from_stdin_and_supports_advisory_gate( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(DIRTY_DIFF_WITH_TELEMETRY_GAP)) + + exit_code = main(["--diff", "-", "--severity-gate", "advisory"]) + + out = capsys.readouterr().out + assert exit_code == 1 + assert "critic: 0 blocking, 1 advisory" in out diff --git a/tests/test_provider_fallback_telemetry.py b/tests/test_provider_fallback_telemetry.py new file mode 100644 index 0000000..e38f88d --- /dev/null +++ b/tests/test_provider_fallback_telemetry.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import contextlib +import io +import json +import subprocess +import unittest +import uuid +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from agent_framework import ChatResponse, Message + +from maf_starter.config import Settings, load_settings +from maf_starter.provider_fallback import ( + _messages_to_prompt, + _run_subprocess, + build_fallback_middleware, +) +from maf_starter.routing_policy import RoutingPlan +from maf_starter.routing_types import ChainStep +from maf_starter.telemetry import emit_failure_telemetry + + +SCRATCH_ROOT = Path(__file__).resolve().parents[1] / ".tmp-tests" + + +class RepoScratchTestCase(unittest.TestCase): + def make_scratch_dir(self) -> Path: + path = SCRATCH_ROOT / uuid.uuid4().hex + path.mkdir(parents=True, exist_ok=False) + self.addCleanup(lambda: path.exists() and __import__("shutil").rmtree(path, ignore_errors=True)) + return path + + +class ProviderFallbackTelemetryTests(RepoScratchTestCase): + def _make_settings(self, root: Path) -> Settings: + entities = root / "entities" + repo = root / "repo" + entities.mkdir() + repo.mkdir() + env = { + "MAF_API_KEY": "test-key", + "MAF_REPO_ROOT": str(repo), + "MAF_ENTITIES_DIR": str(entities), + "MAF_FALLBACK_CHAIN": "gemini-cli:gemini-2.5-pro,claude-cli:claude-sonnet-4-6", + } + with patch.dict("os.environ", env, clear=False): + return load_settings(project_root=root, env_path=root / ".missing-env") + + @staticmethod + def _make_route_plan() -> RoutingPlan: + return RoutingPlan( + mode="auto", + route_lane="auto", + tier="standard", + rationale="test route", + primary_provider="gemini", + primary_model="gemini-2.5-pro", + requested_provider="gemini", + requested_model="gemini-2.5-pro", + fallback_steps=( + ChainStep("gemini-cli", "gemini-2.5-pro"), + ChainStep("claude-cli", "claude-sonnet-4-6"), + ), + ) + + @staticmethod + def _make_context() -> SimpleNamespace: + return SimpleNamespace( + messages=[Message(role="user", text="hello fallback")], + options={}, + kwargs={}, + metadata={}, + result=None, + ) + + def test_emit_failure_telemetry_writes_single_parseable_json_line(self) -> None: + stream = io.StringIO() + with contextlib.redirect_stderr(stream): + emit_failure_telemetry("provider_failed", provider="gemini", error="quota exceeded") + + lines = stream.getvalue().strip().splitlines() + self.assertEqual(len(lines), 1) + payload = json.loads(lines[0]) + self.assertEqual(payload["event"], "provider_failed") + self.assertEqual(payload["provider"], "gemini") + self.assertEqual(payload["error"], "quota exceeded") + self.assertIn("timestamp", payload) + + def test_emit_failure_telemetry_swallows_stderr_write_failures(self) -> None: + fake_stderr = SimpleNamespace( + write=lambda _: (_ for _ in ()).throw(RuntimeError("stderr failed")), + flush=lambda: None, + ) + with patch("sys.stderr", fake_stderr): + self.assertIsNone(emit_failure_telemetry("provider_failed", provider="gemini", error="boom")) + + def test_fallback_middleware_emits_primary_step_and_exhausted_events(self) -> None: + root = self.make_scratch_dir() + settings = self._make_settings(root) + middleware = build_fallback_middleware( + settings, + primary_provider="gemini", + primary_model="gemini-2.5-pro", + routing_mode="auto", + ) + context = self._make_context() + stderr = io.StringIO() + + async def call_next(): + raise RuntimeError("rate limit from primary") + + attempts = [ + RuntimeError("rate limit in gemini-cli fallback"), + RuntimeError("quota exhausted in claude fallback"), + ] + + async def fake_execute_chain_step(**_kwargs): + raise attempts.pop(0) + + with ( + patch("maf_starter.provider_fallback.build_routing_plan", return_value=self._make_route_plan()), + patch("maf_starter.provider_fallback._execute_chain_step", side_effect=fake_execute_chain_step), + contextlib.redirect_stderr(stderr), + ): + with self.assertRaisesRegex(RuntimeError, "quota exhausted in claude fallback"): + __import__("asyncio").run(middleware(context, call_next)) + + events = [json.loads(line)["event"] for line in stderr.getvalue().strip().splitlines()] + self.assertEqual(events, ["provider_failed", "fallback_step_failed", "fallback_step_failed", "fallback_exhausted"]) + + def test_fallback_middleware_emits_recovery_event_when_fallback_succeeds(self) -> None: + root = self.make_scratch_dir() + settings = self._make_settings(root) + middleware = build_fallback_middleware( + settings, + primary_provider="gemini", + primary_model="gemini-2.5-pro", + routing_mode="auto", + ) + context = self._make_context() + stderr = io.StringIO() + + async def call_next(): + raise RuntimeError("rate limit from primary") + + async def fake_execute_chain_step(**_kwargs): + return ChatResponse(messages=[Message(role="assistant", text="fallback ok")]) + + with ( + patch("maf_starter.provider_fallback.build_routing_plan", return_value=self._make_route_plan()), + patch("maf_starter.provider_fallback._execute_chain_step", side_effect=fake_execute_chain_step), + contextlib.redirect_stderr(stderr), + ): + __import__("asyncio").run(middleware(context, call_next)) + + payloads = [json.loads(line) for line in stderr.getvalue().strip().splitlines()] + self.assertEqual(payloads[0]["event"], "provider_failed") + self.assertEqual(payloads[1]["event"], "fallback_succeeded") + self.assertNotIn("fallback_exhausted", [payload["event"] for payload in payloads]) + self.assertIsInstance(context.result, ChatResponse) + + def test_run_subprocess_rejects_oversized_output(self) -> None: + completed = subprocess.CompletedProcess(args=["codex.cmd"], returncode=0, stdout="x" * 32, stderr="") + with ( + patch("maf_starter.provider_fallback.subprocess.run", return_value=completed), + patch("maf_starter.provider_fallback.shutil.which", return_value="codex.cmd"), + patch("maf_starter.provider_fallback.MAX_CLI_OUTPUT_BYTES", 8), + ): + with self.assertRaisesRegex(RuntimeError, "output exceeds 8 bytes"): + _run_subprocess("codex-cli", ["codex.cmd", "exec"], Path.cwd(), None) + + def test_messages_to_prompt_rejects_oversized_prompt(self) -> None: + messages = [Message(role="user", text="x" * 32)] + with patch("maf_starter.provider_fallback.MAX_CLI_PROMPT_BYTES", 8): + with self.assertRaisesRegex(ValueError, "Rendered CLI prompt exceeds 8 bytes"): + _messages_to_prompt(messages) diff --git a/tests/test_worker_boundary.py b/tests/test_worker_boundary.py index d0abe6d..5b251da 100644 --- a/tests/test_worker_boundary.py +++ b/tests/test_worker_boundary.py @@ -1,6 +1,9 @@ from __future__ import annotations import asyncio +import contextlib +import io +import json import unittest from maf_starter.worker_boundary import WorkerBoundary, WorkerProfile @@ -71,19 +74,25 @@ async def controlled_workflow(): async def test_status_records_error_on_exception(self) -> None: boundary = WorkerBoundary() + stderr = io.StringIO() async def failing_workflow(): raise ValueError("something went wrong") - boundary.submit_async("run-3", failing_workflow) - # Yield so the task can run and fail - await asyncio.sleep(0) + with contextlib.redirect_stderr(stderr): + boundary.submit_async("run-3", failing_workflow) + # Yield so the task can run and fail + await asyncio.sleep(0) status = boundary.get_status("run-3") assert status is not None self.assertTrue(status.startswith("error:"), f"Expected error: prefix, got: {status!r}") self.assertIn("something went wrong", status) self.assertTrue(boundary.is_done("run-3")) + payload = json.loads(stderr.getvalue().strip()) + self.assertEqual(payload["event"], "worker_task_failed") + self.assertEqual(payload["run_id"], "run-3") + self.assertEqual(payload["error"], "something went wrong") def test_get_status_returns_none_for_unknown_run(self) -> None: boundary = WorkerBoundary()