diff --git a/.github/workflows/repo-eval-self.yml b/.github/workflows/repo-eval-self.yml new file mode 100644 index 00000000..ed6d75a0 --- /dev/null +++ b/.github/workflows/repo-eval-self.yml @@ -0,0 +1,58 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +name: repo-eval-self + +# dokimi's own evals (.agent-eval/) through the reusable repo-eval.yml, as a +# repository calls it, with two differences: the workflow is this commit's +# (`./`), and so is the dokimi it installs, so a change to the repository +# mode is checked against these tasks before it merges. +# +# A change to a task checks that task. A change to the harness (run.py, the +# Dockerfile, the manifest), to the repository mode or to either workflow +# checks the smoke set: one fix task (hidden tests plus the whole suite) and +# the score-only review task. A dispatch checks all ten by default. +on: + pull_request: + paths: + - '.agent-eval/**' + - 'dokimi/dokimi/repos/**' + - 'dokimi/dokimi/commands/repo*.py' + - 'dokimi/dokimi/schemas/repo_*.json' + - 'dokimi/requirements.lock.txt' + - 'dokimi/pyproject.toml' + - '.github/workflows/repo-eval.yml' + - '.github/workflows/repo-eval-self.yml' + workflow_dispatch: + inputs: + tasks: + description: "`all`, or task ids separated by spaces or commas" + required: false + default: all + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + self: + permissions: + contents: read + uses: ./.github/workflows/repo-eval.yml + with: + # The commit under test: the merge commit on a pull request. + dokimi-ref: ${{ github.sha }} + dokimi-repository: ${{ github.repository }} + manifest: .agent-eval/repo-eval.yaml + tasks: ${{ inputs.tasks || 'changed' }} + smoke-tasks: fix-degrade-pre review-pr-conventions + # Every `paths:` entry above outside .agent-eval/, as a prefix. + smoke-paths: >- + dokimi/dokimi/repos/ dokimi/dokimi/commands/repo dokimi/dokimi/schemas/repo_ + dokimi/requirements.lock.txt dokimi/pyproject.toml .github/workflows/repo-eval.yml + max-parallel: 4 + # intel-ubuntu-latest: see ci.yml. + runs-on: ${{ github.repository_owner == 'intel' && 'intel-ubuntu-latest' || 'ubuntu-latest' }} + timeout-minutes: 60 diff --git a/.github/workflows/repo-eval.yml b/.github/workflows/repo-eval.yml new file mode 100644 index 00000000..61972036 --- /dev/null +++ b/.github/workflows/repo-eval.yml @@ -0,0 +1,421 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +name: repo-eval + +# The no-LLM checks of a repository's evals (dokimi/docs/repo-evaluation-contract.md), +# for repositories to call from their own workflow: +# +# validate `repo validate`: the manifest and every task.json (seconds; a gate) +# scan `repo scan`: the L0 claims of the guidance (report only) +# tasks-check `repo tasks check`, one job per selected task: the untouched +# workspace fails the gate, the reference solution passes it +# +# Nothing here calls a model or reads a secret. The caller pins dokimi twice, +# by the same commit SHA (or tag): in `uses: ...@` and in `dokimi-ref`. The +# ref installs dokimi and, through its requirements.lock.txt, every dependency, +# so a rerun checks with the same framework. A branch name is refused. The +# calling job grants `contents: read`, all any job here asks for. +# +# Task selection (`tasks`): `changed` on a pull request is the tasks whose +# directories it touches, plus `smoke-tasks` when it changes anything else the +# self-check depends on: the eval directory outside its tasks (harness, +# graders, Dockerfile; Markdown excepted), the manifest, the calling workflow +# file, or a path under `smoke-paths`. On a push `changed` diffs against the +# previous tip; on any other event it means `all`. + +on: + workflow_call: + inputs: + dokimi-ref: + description: >- + dokimi commit SHA (40 hex digits) or release tag to install. Use the ref of + the `uses:` line; a branch is refused. + required: true + type: string + dokimi-repository: + description: Repository dokimi-ref is in (owner/name), for testing a fork + required: false + type: string + default: intel/dokimi + manifest: + description: Manifest path relative to the repository root + required: false + type: string + default: .agent-eval/repo-eval.yaml + tasks: + description: "`changed`, `all`, or task ids separated by spaces or commas (empty: `changed`)" + required: false + type: string + default: changed + smoke-tasks: + description: >- + Task ids checked when `changed` finds a change outside the task directories + (empty: every task) + required: false + type: string + default: '' + smoke-paths: + description: >- + More path prefixes (space- or comma-separated) whose change selects `smoke-tasks`, + for example a vendored grader library + required: false + type: string + default: '' + exclude-tasks: + description: Task ids never checked here (space- or comma-separated), for example ones too heavy for CI + required: false + type: string + default: '' + max-parallel: + description: At most this many tasks-check jobs at once + required: false + type: number + default: 4 + warm: + description: >- + Pass --warm to `repo tasks check`: null and oracle run with network so a + cold cache fills (an ephemeral runner starts with empty caches) + required: false + type: boolean + default: false + runs-on: + description: Runner label for every job (a tasks-check job needs docker) + required: false + type: string + default: ubuntu-latest + timeout-minutes: + description: Timeout of each tasks-check job (image build included) + required: false + type: number + default: 180 + +permissions: {} + +jobs: + validate: + name: repo validate + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 20 + permissions: + contents: read + outputs: + dokimi-sha: ${{ steps.ref.outputs.sha }} + id: ${{ steps.select.outputs.id }} + tasks: ${{ steps.select.outputs.tasks }} + steps: + # Full history: `validate` resolves every task base, and `changed` diffs + # against the base branch. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + # A SHA is taken as it is; a tag is resolved to the commit it names, so the + # later jobs install the same commit even if the tag moves meanwhile. + - name: Resolve dokimi-ref + id: ref + env: + REF: ${{ inputs.dokimi-ref }} + DOKIMI_REPO: ${{ inputs.dokimi-repository }} + run: | + if ! [[ "$DOKIMI_REPO" =~ ^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$ ]]; then + echo "::error title=dokimi-repository::dokimi-repository is not owner/name" + exit 1 + fi + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then + sha=$REF + elif [[ "$REF" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + # An annotated tag's commit is its peeled (^{}) line. + sha=$(git ls-remote --tags "https://github.com/$DOKIMI_REPO" \ + | awk -v t="refs/tags/$REF" '$2 == t "^{}" { p = $1 } $2 == t { l = $1 } END { print (p != "" ? p : l) }') + fi + if ! [[ "${sha:-}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error title=dokimi-ref::dokimi-ref is neither a 40-digit commit SHA nor a tag of $DOKIMI_REPO" + exit 1 + fi + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "dokimi $DOKIMI_REPO@$REF -> $sha" + - name: Install dokimi + env: + SHA: ${{ steps.ref.outputs.sha }} + DOKIMI_REPO: ${{ inputs.dokimi-repository }} + run: | + pip install -c "https://raw.githubusercontent.com/$DOKIMI_REPO/$SHA/dokimi/requirements.lock.txt" \ + "dokimi @ git+https://github.com/$DOKIMI_REPO@$SHA#subdirectory=dokimi" + - name: repo validate + env: + MANIFEST: ${{ inputs.manifest }} + run: | + status=0 + dokimi repo validate . --manifest "$MANIFEST" || status=$? + dokimi repo validate . --manifest "$MANIFEST" --json > "$RUNNER_TEMP/validate.json" || true + python - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import json, os + path = os.path.join(os.environ["RUNNER_TEMP"], "validate.json") + try: + d = json.load(open(path, encoding="utf-8")) + except (OSError, ValueError): + print("### repo validate", "", "The manifest could not be read; see the job log.", sep="\n") + raise SystemExit + tasks = d["tasks"] + print("### repo validate", "", f"{'ok' if d['ok'] else 'FAILED'}: `{d['manifest']['manifest']}`, " + f"{tasks['count']} task(s), {len(d['guidance_files'])} guidance file(s)", sep="\n") + for kind in ("errors", "warnings", "notes"): + for line in d[kind]: + print(f"- {kind[:-1]}: {line}") + PY + exit "$status" + - name: Select tasks + id: select + env: + EVENT: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + BEFORE: ${{ github.event.before }} + WORKFLOW_REF: ${{ github.workflow_ref }} + REPOSITORY: ${{ github.repository }} + MANIFEST: ${{ inputs.manifest }} + TASKS: ${{ inputs.tasks }} + SMOKE_TASKS: ${{ inputs.smoke-tasks }} + SMOKE_PATHS: ${{ inputs.smoke-paths }} + EXCLUDE_TASKS: ${{ inputs.exclude-tasks }} + run: | + python - <<'PY' + import json, os, re, subprocess, sys + from pathlib import Path + + env = os.environ + d = json.load(open(os.path.join(env["RUNNER_TEMP"], "validate.json"), encoding="utf-8")) + m = d["manifest"] + + def ids(text): + return [t for t in re.split(r"[\s,]+", text) if t] + + def out(**kv): + with open(env["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + for k, v in kv.items(): + f.write(f"{k}={v}\n") + + tasks_dir = m["tasks"] + if not tasks_dir: # a guidance-only manifest + print("guidance-only manifest: no tasks to check") + out(id=m["id"], tasks="[]") + sys.exit() + all_ids = sorted(p.parent.name for p in Path(tasks_dir).glob("*/task.json")) + + def changed_files(): + # HEAD is GitHub's merge commit; its first parent is the base tip now, while base.sha is + # the base at PR-open time and would count what the base gained since as the PR's change + if env["EVENT"] == "pull_request" and env["BASE_SHA"]: + merge = subprocess.run(["git", "rev-parse", "-q", "--verify", "HEAD^2"], capture_output=True).returncode == 0 + rng = ["HEAD^1...HEAD" if merge else f"{env['BASE_SHA']}...HEAD"] + elif env["EVENT"] == "push" and env["BEFORE"].strip("0"): + rng = [env["BEFORE"], "HEAD"] + else: + return None + r = subprocess.run(["git", "diff", "--no-renames", "--name-only", "-z", *rng], capture_output=True, text=True) + if r.returncode: + print(f"::warning::git diff {' '.join(rng)} failed, checking every task: {r.stderr.strip()}") + return None + return r.stdout.split('\0') if r.stdout else [] + + import posixpath + # empty is the default: a caller forwarding an unset dispatch input passes '', not `changed` + mode = env["TASKS"].strip() if ids(env["TASKS"]) else "changed" + if mode == "all": + sel = list(all_ids) + elif mode == "changed": + files = changed_files() + if files is None: + sel = list(all_ids) + else: + prefix = tasks_dir.rstrip("/") + "/" + sel = sorted({f[len(prefix):].split("/", 1)[0] for f in files if f.startswith(prefix)}) + sel = [t for t in sel if t in all_ids] # a deleted task has nothing to check + # the calling workflow: owner/repo/.github/workflows/x.yml@ref + caller = env["WORKFLOW_REF"].split("@", 1)[0].removeprefix(env["REPOSITORY"] + "/") + eval_dir = m["eval_dir"].rstrip("/") + "/" + # the same separators as ids(); normalised like the manifest, a trailing slash kept + roots = [posixpath.normpath(p) + ("/" if p.endswith("/") and p != "/" else "") for p in ids(env["SMOKE_PATHS"])] + manifest_normalized = posixpath.normpath(env["MANIFEST"]) + + def harness(f): + if f in (manifest_normalized, caller) or any(f.startswith(r) for r in roots): + return True # an explicit smoke path wins over the Markdown exemption below + if f.startswith(eval_dir) and f.endswith(".md"): + return False # Markdown in the eval dir, its tasks dir included, selects nothing + if f.startswith(prefix): + # a file under prefix (tasks dir) whose first segment is not an existing task id + # is a shared helper (e.g., tasks/common.py) or a deleted task's file: harness + first_segment = f[len(prefix):].split("/", 1)[0] + if first_segment not in all_ids: + return True + return f.startswith(eval_dir) and not f.startswith(prefix) + + hits = [f for f in files if harness(f)] + if hits: + smoke = ids(env["SMOKE_TASKS"]) or all_ids + print(f"outside the task directories: {' '.join(hits[:10])}; adding the smoke set") + unknown = [t for t in smoke if t not in all_ids] + if unknown: + sys.exit(f"::error title=smoke-tasks::no such task(s): {', '.join(unknown)}") + sel = sorted(set(sel) | set(smoke)) + else: + sel = ids(mode) + unknown = [t for t in sel if t not in all_ids] + if unknown: + sys.exit(f"::error title=tasks::no such task(s): {', '.join(unknown)}") + excluded = set(ids(env["EXCLUDE_TASKS"])) + sel = [t for t in dict.fromkeys(sel) if t not in excluded] + print(f"tasks-check: {' '.join(sel) or 'none'}") + out(id=m["id"], tasks=json.dumps(sel)) + with open(env["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f: + f.write(f"\ntasks-check ({mode}): {', '.join(f'`{t}`' for t in sel) or 'none'}\n") + PY + + scan: + name: repo scan + needs: validate + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + - name: Install dokimi + env: + SHA: ${{ needs.validate.outputs.dokimi-sha }} + DOKIMI_REPO: ${{ inputs.dokimi-repository }} + run: | + pip install -c "https://raw.githubusercontent.com/$DOKIMI_REPO/$SHA/dokimi/requirements.lock.txt" \ + "dokimi @ git+https://github.com/$DOKIMI_REPO@$SHA#subdirectory=dokimi" + # Report only: a failing claim does not fail the job (an unreadable + # manifest still does). The table lists the failing claims. + - name: repo scan + env: + MANIFEST: ${{ inputs.manifest }} + run: | + dokimi repo scan . --manifest "$MANIFEST" --out "$RUNNER_TEMP/repo-readiness.json" + python - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import json, os + d = json.load(open(os.path.join(os.environ["RUNNER_TEMP"], "repo-readiness.json"), encoding="utf-8")) + c = d["counts"] + + def cell(text): + return str(text).replace("|", "\\|").replace("\n", " ") + + print("### repo scan (L0, report only)", "", + f"{len(d['files'])} guidance file(s), adapters: {', '.join(d['adapters']) or 'none'}", "", + "| claims | ok | unresolved | fail |", "|---|---|---|---|", + f"| {c['total']} | {c['ok']} | {c['unresolved']} | {c['fail']} |", sep="\n") + for w in d["warnings"]: + print(f"- warning: {cell(w)}") + fails = [x for x in d["claims"] if x["status"].split("_", 1)[0] == "fail"] + if fails: + print("", "| file | line | kind | claim | why |", "|---|---|---|---|---|", sep="\n") + for x in fails[:100]: + print(f"| {cell(x['file'])} | {x['line']} | {cell(x.get('kind'))} | " + f"`{cell(str(x.get('claim'))[:80])}` | {cell(x.get('why'))} |") + if len(fails) > 100: + print(f"\n{len(fails) - 100} more in the repo-readiness.json artifact") + PY + - name: Upload the scan report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: repo-eval-scan-${{ needs.validate.outputs.id }} + path: ${{ runner.temp }}/repo-readiness.json + if-no-files-found: ignore + + tasks-check: + name: repo tasks check ${{ matrix.task }} + needs: validate + if: needs.validate.outputs.tasks != '[]' + runs-on: ${{ inputs.runs-on }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: ${{ inputs.max-parallel }} + matrix: + task: ${{ fromJSON(needs.validate.outputs.tasks) }} + steps: + # Full history: task workspaces are checked out from the task's base commit. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + - name: Install dokimi + env: + SHA: ${{ needs.validate.outputs.dokimi-sha }} + DOKIMI_REPO: ${{ inputs.dokimi-repository }} + run: | + pip install -c "https://raw.githubusercontent.com/$DOKIMI_REPO/$SHA/dokimi/requirements.lock.txt" \ + "dokimi @ git+https://github.com/$DOKIMI_REPO@$SHA#subdirectory=dokimi" + # Builds the image from the manifest's Dockerfile (the tree without its + # tasks), then grades the null and the oracle workspace in it. + - name: repo tasks check + env: + MANIFEST: ${{ inputs.manifest }} + TASK: ${{ matrix.task }} + WARM: ${{ inputs.warm }} + # Each task's verdict in the log as it lands, not when the step ends. + PYTHONUNBUFFERED: '1' + run: | + warm=() + if [ "$WARM" = true ]; then warm=(--warm); fi + dokimi repo tasks check . --manifest "$MANIFEST" --task "$TASK" --out "$RUNNER_TEMP/repo-check" "${warm[@]}" + # always(): a hung oracle ends in the job timeout, which cancels the job; a + # cancelled job still runs these steps, so the run leaves a verdict. + - name: Summary + if: ${{ always() }} + env: + JOB_STATUS: ${{ job.status }} + TASK: ${{ matrix.task }} + run: | + python - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import json, os + path = os.path.join(os.environ["RUNNER_TEMP"], "repo-check", "check", "check.json") + if not os.path.isfile(path): + if os.environ["JOB_STATUS"] == "cancelled": + # the job timeout (or a cancel) stopped the check before it wrote its verdict + problem = "the job was cancelled (timeout-minutes?) before the check finished" + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump({"tasks": [{"task": os.environ["TASK"], "verdict": "timeout", + "problems": [problem]}]}, f) + else: + print("### repo tasks check", "", "No check.json: the check stopped before grading; see the job log.", + sep="\n") + raise SystemExit + for r in json.load(open(path, encoding="utf-8"))["tasks"]: + print(f"### repo tasks check `{r['task']}`: {r['verdict'].upper()}", "", sep="\n") + for kind in ("null", "oracle"): + g = r.get(kind) + if g: + print(f"- {kind}: {g.get('status')}, success {g.get('success')}, score {g.get('score')}") + for p in r["problems"]: + print(f"- problem: {p}") + PY + - name: Upload the check results + if: ${{ always() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: repo-eval-check-${{ needs.validate.outputs.id }}-${{ matrix.task }} + path: | + ${{ runner.temp }}/repo-check/check/check.json + ${{ runner.temp }}/repo-check/check/*/grade.json + ${{ runner.temp }}/repo-check/check/*/*.log + ${{ runner.temp }}/repo-check/check/*/*.err + if-no-files-found: ignore diff --git a/dokimi/changelog.d/20261019_144048_napetrov_d8-repo-eval-workflow.md b/dokimi/changelog.d/20261019_144048_napetrov_d8-repo-eval-workflow.md new file mode 100644 index 00000000..903ab5ba --- /dev/null +++ b/dokimi/changelog.d/20261019_144048_napetrov_d8-repo-eval-workflow.md @@ -0,0 +1,3 @@ +### Changed + +- **`repo validate` refuses task ids that contain whitespace or a comma** — a task directory whose name has a space, tab or comma is now a `task.json` error instead of passing validation. The repo-eval workflow's `tasks`, `smoke-tasks` and `exclude-tasks` inputs are split on whitespace and commas, so such a task could never be named there. Rename the task directory to a name without whitespace or commas. diff --git a/dokimi/changelog.d/20261021_100000_napetrov_repo-eval-timeout-verdict.md b/dokimi/changelog.d/20261021_100000_napetrov_repo-eval-timeout-verdict.md new file mode 100644 index 00000000..2b033cbc --- /dev/null +++ b/dokimi/changelog.d/20261021_100000_napetrov_repo-eval-timeout-verdict.md @@ -0,0 +1,7 @@ +### Fixed + +- **repo-eval: a hung oracle leaves a verdict** — the Summary and Upload steps of + `repo-eval.yml` run with `always()`, and a job cancelled by `timeout-minutes` before + `check.json` exists records a `timeout` verdict for the task. The self-check + (`repo-eval-self.yml`) now also runs on changes to `commands/repo*.py`, + `schemas/repo_*.json`, `requirements.lock.txt` and `pyproject.toml`. diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index d4bda8dc..e4af28c1 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -238,7 +238,7 @@ belongs to the repository's grader and is passed through unread. | Key | Required | Read by | Meaning | |---|---|---|---| -| `id` | yes | framework | equals the directory name | +| `id` | yes | framework | equals the directory name, and contains no whitespace or comma: the workflow's `tasks`, `smoke-tasks` and `exclude-tasks` inputs are lists of ids split on whitespace and commas, so `repo validate` refuses an id containing either | | `family` | yes | framework (reporting) | free string; common values: `build`, `fix`, `feature`, `test`, `review` | | `base` | yes | framework | commit to snapshot: a hex SHA, optionally followed by `^` (the parent, the usual form for a mined fix). Branch names, tags and `HEAD` are refused, because repetitions of one task must get one snapshot. Use the full 40-character SHA; a shorter one is accepted with a warning. It must resolve in the source. `meta.json` records it as `base_rev` and the resolved full SHA as `base_sha` | | `prompt` | yes | framework | what the agent is told | @@ -870,3 +870,59 @@ python cli.py repo compare repo-runs/pilot --baseline guidance:none --metric roo `@20250929`) or its provider-prefixed form is the same model, `-v2` against `-v3` is not. `repo compare` warns the same way when the two arms of a comparison were answered by different models. + +CI for a repository: `.github/workflows/repo-eval.yml` is a reusable workflow +that runs the no-LLM checks on the caller's checkout and needs no secret. + +- `validate` runs `repo validate` and gates the other two jobs; `scan` runs `repo scan` + (report only: the claim counts and failing claims go to the job summary, the + `repo_readiness.v1` report to an artifact); `tasks-check` runs `repo tasks check` + for each selected task in a job of its own (docker; the image is built from the + manifest's Dockerfile), and uploads `check.json` and the grade logs. If the job is + cancelled by its `timeout-minutes` before the check finishes, the Summary step + writes a `check.json` with the verdict `timeout` for the task. +- `tasks: changed` (the default, also for an empty value) checks, on a pull request, the tasks whose + directories it touches, plus `smoke-tasks` (empty: every task) when it changes + the eval directory outside its tasks (Markdown there is excepted, unless it is the manifest, + the calling workflow file or under a `smoke-paths` entry), the manifest, the calling workflow + file or a path under `smoke-paths` (entries are space- or comma-separated prefixes, + normalised like the manifest path; keep the trailing slash for a directory). A file in + the tasks directory that is not under an existing task (a shared helper, or the files of + a deleted task) does too. On a push it diffs against the previous tip, on any other event + it checks every task. `all` or a list of + task ids selects directly; `exclude-tasks` always drops tasks. +- Pin dokimi by a commit SHA or a release tag, the same one in `uses:` and in + `dokimi-ref` (a branch is refused). It installs + `dokimi @ git+https://github.com/intel/dokimi@#subdirectory=dokimi` with + that commit's `requirements.lock.txt` as constraints, so a rerun checks with the + same framework and dependencies. Moving the pin is a change to the calling + workflow, so it runs the smoke set. +- Other inputs: `manifest`, `max-parallel` (4), `warm` (`--warm`, for a cache that + starts empty on every runner), `runs-on` (`ubuntu-latest`), `timeout-minutes` + (180, per task) and `dokimi-repository` (`intel/dokimi`). + +```yaml +# .github/workflows/agent-evals.yml in the repository +name: Agent evals +on: + pull_request: + paths: ['dev/agent_evals/**', '**/AGENTS.md', '.github/workflows/agent-evals.yml'] + schedule: + - cron: '0 3 * * 0' + workflow_dispatch: +permissions: {} +jobs: + evals: + permissions: + contents: read + uses: intel/dokimi/.github/workflows/repo-eval.yml@ # vX.Y.Z + with: + dokimi-ref: + manifest: dev/agent_evals/repo-eval.yaml + smoke-tasks: build_kmeans_host fix_finiteness_mut review_seeded_fc + max-parallel: 4 + warm: true +``` + +dokimi runs the workflow on its own `.agent-eval/` (`repo-eval-self.yml`), with +the commit under test as `dokimi-ref`. diff --git a/dokimi/dokimi/repos/manifest.py b/dokimi/dokimi/repos/manifest.py index fe10b884..9ab20dd3 100644 --- a/dokimi/dokimi/repos/manifest.py +++ b/dokimi/dokimi/repos/manifest.py @@ -417,6 +417,12 @@ def _glob_regex(pattern: str) -> re.Pattern[str]: def _check_task(tree: RepoTree, task_dir: str, report: RepoCheckReport) -> None: name = PurePosixPath(task_dir).name where = f"{task_dir}/task.json" + if re.search(r"[\s,]", name): + # tasks, smoke-tasks and exclude-tasks are lists of ids split on whitespace and commas, so an id + # containing either could never be named there + report.errors.append(f"{where}: task id {name!r} contains whitespace or a comma; task ids are " + "split on whitespace and commas in the workflow's tasks, smoke-tasks and exclude-tasks inputs") + return try: raw = json.loads(tree.read_text(where)) except (json.JSONDecodeError, UnicodeDecodeError) as exc: diff --git a/dokimi/tests/test_repo_eval_task_selection.py b/dokimi/tests/test_repo_eval_task_selection.py new file mode 100644 index 00000000..eff57aa4 --- /dev/null +++ b/dokimi/tests/test_repo_eval_task_selection.py @@ -0,0 +1,363 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""The reusable repo-eval workflow's ``Select tasks`` step, run for real. + +The step's Python is lifted out of ``.github/workflows/repo-eval.yml`` and run in a +scratch git repository with the environment GitHub gives it, so a change to the +selector (path splitting, the smoke set, the manifest comparison) fails here and not +on a caller's pull request. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest +import yaml + +WORKFLOW = Path(__file__).resolve().parents[2] / ".github" / "workflows" / "repo-eval.yml" +EVAL_DIR = ".agent-eval" +TASKS = f"{EVAL_DIR}/tasks" + +pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="needs git") + + +def _steps() -> list[dict[str, Any]]: + doc = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + return [s for job in doc["jobs"].values() for s in job.get("steps", [])] + + +def _select_script() -> str: + run = next(s for s in _steps() if s.get("id") == "select")["run"] + match = re.search(r"python - <<'PY'\n(.*)\nPY\s*$", run, re.S) + assert match, "the Select tasks step is no longer a python heredoc" + return match.group(1) + + +def _git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=repo, check=True, capture_output=True, text=True + ).stdout + + +def _write(repo: Path, rel: str, text: str = "x\n") -> None: + (repo / rel).parent.mkdir(parents=True, exist_ok=True) + (repo / rel).write_text(text, encoding="utf-8") + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + root = tmp_path / "repo" + root.mkdir() + for cmd in ( + ["init", "-q", "-b", "main"], + ["config", "user.email", "t@example.com"], + ["config", "user.name", "t"], + ["config", "commit.gpgsign", "false"], + ): + _git(root, *cmd) + for task in ("t1", "my task"): + _write(root, f"{TASKS}/{task}/task.json", "{}") + _write(root, f"{TASKS}/{task}/grader.py") + _write(root, "README.md") + _write(root, f"{EVAL_DIR}/repo-eval.yaml") + _git(root, "add", "-A") + _git(root, "commit", "-q", "-m", "base") + _git(root, "update-ref", "refs/remotes/origin/main", "HEAD") + return root + + +def _run_step( + repo: Path, + tmp_path: Path, + *, + commit: bool = True, + manifest_tasks: str | None = TASKS, + **overrides: str, +) -> tuple[subprocess.CompletedProcess, Path]: + """Commit the working tree (unless ``commit`` is false) and run the step on HEAD.""" + if commit: + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "change", "--allow-empty") + temp = tmp_path / "runner" + temp.mkdir(exist_ok=True) + (temp / "validate.json").write_text( + json.dumps({"manifest": {"id": "demo", "tasks": manifest_tasks, "eval_dir": EVAL_DIR}}), + encoding="utf-8", + ) + env = { + **os.environ, + "RUNNER_TEMP": str(temp), + "GITHUB_OUTPUT": str(temp / "out"), + "GITHUB_STEP_SUMMARY": str(temp / "summary"), + "EVENT": "pull_request", + "BEFORE": "", + "BASE_SHA": "origin/main", + "WORKFLOW_REF": "o/r/.github/workflows/ci.yml@refs/heads/x", + "REPOSITORY": "o/r", + "MANIFEST": f"{EVAL_DIR}/repo-eval.yaml", + "TASKS": "changed", + "SMOKE_TASKS": "t1", + "SMOKE_PATHS": "", + "EXCLUDE_TASKS": "", + **overrides, + } + (temp / "out").write_text("", encoding="utf-8") + result = subprocess.run( + [sys.executable, "-c", _select_script()], + cwd=repo, + env=env, + capture_output=True, + text=True, + check=False, + ) + return result, temp + + +def _select(repo: Path, tmp_path: Path, **overrides: str) -> list[str]: + result, temp = _run_step(repo, tmp_path, **overrides) + assert result.returncode == 0, result.stdout + result.stderr + line = next( + ln + for ln in (temp / "out").read_text(encoding="utf-8").splitlines() + if ln.startswith("tasks=") + ) + return json.loads(line.removeprefix("tasks=")) + + +def test_a_task_directory_with_a_space_is_selected_whole(repo, tmp_path): + _write(repo, f"{TASKS}/my task/grader.py", "changed\n") + assert _select(repo, tmp_path) == ["my task"] + + +def test_a_quoted_file_name_does_not_hide_its_task(repo, tmp_path): + _write(repo, f'{TASKS}/t1/we"irdé name.txt') + assert _select(repo, tmp_path) == ["t1"] + + +def test_both_sides_of_a_move_between_tasks_are_selected(repo, tmp_path): + _write(repo, f"{TASKS}/t1/big.py", "".join(f"line {i}\n" for i in range(200))) + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "more") + _git(repo, "update-ref", "refs/remotes/origin/main", "HEAD") + (repo / TASKS / "t1" / "big.py").rename(repo / TASKS / "my task" / "big.py") + assert _select(repo, tmp_path) == ["my task", "t1"] + + +def test_a_shared_helper_directly_in_the_tasks_dir_runs_the_smoke_set(repo, tmp_path): + _write(repo, f"{TASKS}/common.py") + assert _select(repo, tmp_path) == ["t1"] + + +def test_a_shared_directory_in_the_tasks_dir_runs_the_smoke_set(repo, tmp_path): + _write(repo, f"{TASKS}/_shared/lib.py") + assert _select(repo, tmp_path) == ["t1"] + + +def test_a_deleted_task_directory_runs_the_smoke_set(repo, tmp_path): + shutil.rmtree(repo / TASKS / "my task") + assert _select(repo, tmp_path) == ["t1"] + + +def test_a_harness_file_in_the_eval_dir_adds_the_smoke_set_to_the_changed_task(repo, tmp_path): + _write(repo, f"{EVAL_DIR}/Dockerfile") + _write(repo, f"{TASKS}/my task/grader.py", "changed\n") + assert _select(repo, tmp_path) == ["my task", "t1"] + + +@pytest.mark.parametrize( + "manifest", [f"{EVAL_DIR}/repo-eval.yaml", f"./{EVAL_DIR}/../repo-eval.yaml"] +) +def test_the_manifest_is_compared_normalised(repo, tmp_path, manifest): + if manifest.startswith("./"): + _write(repo, "repo-eval.yaml") + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "m") + _git(repo, "update-ref", "refs/remotes/origin/main", "HEAD") + _write(repo, "repo-eval.yaml", "changed\n") + else: + _write(repo, f"{EVAL_DIR}/repo-eval.yaml", "changed\n") + assert _select(repo, tmp_path, MANIFEST=manifest) == ["t1"] + + +def test_an_empty_smoke_set_means_every_task(repo, tmp_path): + _write(repo, f"{EVAL_DIR}/Dockerfile") + assert _select(repo, tmp_path, SMOKE_TASKS="") == ["my task", "t1"] + + +def test_a_guidance_only_manifest_checks_no_task(repo, tmp_path): + _write(repo, f"{EVAL_DIR}/Dockerfile") + assert _select(repo, tmp_path, manifest_tasks=None) == [] + + +def test_an_unrelated_file_selects_nothing(repo, tmp_path): + _write(repo, "README.md", "changed\n") + assert _select(repo, tmp_path) == [] + + +def test_markdown_in_the_tasks_dir_selects_nothing(repo, tmp_path): + _write(repo, f"{TASKS}/README.md", "changed\n") + assert _select(repo, tmp_path) == [] + + +@pytest.mark.parametrize("smoke_path", [f"{EVAL_DIR}/rubric.md", f"{EVAL_DIR}/prompts/"]) +def test_a_markdown_file_named_in_smoke_paths_runs_the_smoke_set(repo, tmp_path, smoke_path): + _write(repo, f"{EVAL_DIR}/prompts/p.md", "changed\n") + _write(repo, f"{EVAL_DIR}/rubric.md", "changed\n") + assert _select(repo, tmp_path, SMOKE_PATHS=smoke_path) == ["t1"] + + +def test_an_explicit_task_list_is_checked_as_given(repo, tmp_path): + _write(repo, "README.md", "changed\n") + assert _select(repo, tmp_path, TASKS="t1") == ["t1"] + + +@pytest.mark.parametrize("tasks", ["", " ", ","]) +def test_an_empty_task_list_means_changed(repo, tmp_path, tasks): + _write(repo, f"{TASKS}/my task/grader.py", "changed\n") + assert _select(repo, tmp_path, TASKS=tasks) == ["my task"] + + +def test_all_minus_excluded_tasks(repo, tmp_path): + _write(repo, "README.md", "changed\n") + assert _select(repo, tmp_path, TASKS="all", EXCLUDE_TASKS="t1") == ["my task"] + + +def test_a_push_diffs_against_the_previous_tip(repo, tmp_path): + _write(repo, f"{TASKS}/my task/grader.py", "changed\n") + assert _select(repo, tmp_path, EVENT="push", BEFORE="origin/main") == ["my task"] + + +def test_a_push_without_a_previous_tip_checks_every_task(repo, tmp_path): + _write(repo, f"{TASKS}/my task/grader.py", "changed\n") + assert _select(repo, tmp_path, EVENT="push", BEFORE="0" * 40) == ["my task", "t1"] + + +def test_a_push_after_a_force_push_diffs_the_two_tips_directly(repo, tmp_path): + # the old tip is not an ancestor of the new HEAD: its own change counts too + _git(repo, "checkout", "-q", "-b", "old") + _write(repo, f"{TASKS}/t1/grader.py", "changed\n") + _git(repo, "commit", "-q", "-a", "-m", "old tip") + old = _git(repo, "rev-parse", "HEAD").strip() + _git(repo, "checkout", "-q", "main") + _write(repo, f"{TASKS}/my task/grader.py", "changed\n") + assert _select(repo, tmp_path, EVENT="push", BEFORE=old) == ["my task", "t1"] + + +def test_a_pull_request_without_a_base_sha_checks_every_task(repo, tmp_path): + _write(repo, f"{TASKS}/my task/grader.py", "changed\n") + assert _select(repo, tmp_path, BASE_SHA="") == ["my task", "t1"] + + +def test_a_merge_commit_ignores_what_the_base_gained_after_the_pr_opened(repo, tmp_path): + # base.sha stays at the fork point while the checkout merges into the base's current tip + fork = _git(repo, "rev-parse", "HEAD").strip() + _git(repo, "checkout", "-q", "-b", "pr") + _write(repo, f"{TASKS}/t1/grader.py", "changed\n") + _git(repo, "commit", "-q", "-a", "-m", "pr") + _git(repo, "checkout", "-q", "main") + _write(repo, f"{TASKS}/my task/grader.py", "changed\n") + _write(repo, f"{EVAL_DIR}/Dockerfile") + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "main moves on") + _git(repo, "merge", "-q", "--no-ff", "--no-edit", "pr") + assert _select(repo, tmp_path, commit=False, BASE_SHA=fork, SMOKE_TASKS="my task") == ["t1"] + + +def test_a_change_to_the_calling_workflow_runs_the_smoke_set(repo, tmp_path): + _write(repo, ".github/workflows/ci.yml", "changed\n") + assert _select(repo, tmp_path) == ["t1"] + + +def test_every_path_that_triggers_the_self_check_runs_the_smoke_set(repo, tmp_path): + self_check = WORKFLOW.with_name("repo-eval-self.yml") + doc = yaml.safe_load(self_check.read_text(encoding="utf-8")) + paths = doc[True]["pull_request"]["paths"] # YAML 1.1 reads the `on` key as True + caller = f"o/r/.github/workflows/{self_check.name}@refs/pull/1/merge" + # a file each filter matches: `**` as a nested file, `*` as nothing and as a suffix + files = sorted( + { + p.replace("**", "x/y.py").replace("*", star) + for p in paths + if not p.startswith(f"{EVAL_DIR}/") + for star in ("", "_x") + } + ) + missed = [] + for f in files: + _write(repo, f, "changed\n") + smoke = doc["jobs"]["self"]["with"]["smoke-paths"] + if _select(repo, tmp_path, SMOKE_PATHS=smoke, WORKFLOW_REF=caller) != ["t1"]: + missed.append(f) + _git(repo, "update-ref", "refs/remotes/origin/main", "HEAD") + assert files and not missed + + +@pytest.mark.parametrize("smoke_paths", ["./vendor/", "vendor/,other/", "other/ vendor/"]) +def test_smoke_paths_are_normalised_and_split_like_ids(repo, tmp_path, smoke_paths): + _write(repo, "vendor/grader/lib.py", "changed\n") + assert _select(repo, tmp_path, SMOKE_PATHS=smoke_paths) == ["t1"] + + +def test_a_smoke_directory_without_its_slash_is_a_prefix_as_given(repo, tmp_path): + _write(repo, "vendor-old/x.py", "changed\n") + assert _select(repo, tmp_path, SMOKE_PATHS="vendor/") == [] + + +def test_an_unknown_explicit_task_fails_the_run(repo, tmp_path): + result, _ = _run_step(repo, tmp_path, TASKS="nope") + assert result.returncode != 0 and "no such task(s): nope" in result.stdout + result.stderr + + +def test_an_unknown_smoke_task_fails_the_run(repo, tmp_path): + _write(repo, f"{EVAL_DIR}/Dockerfile") + result, _ = _run_step(repo, tmp_path, SMOKE_TASKS="nope") + assert result.returncode != 0 and "no such task(s): nope" in result.stdout + result.stderr + + +def test_an_unreadable_previous_tip_checks_every_task(repo, tmp_path): + _write(repo, f"{TASKS}/my task/grader.py", "changed\n") + assert _select(repo, tmp_path, EVENT="push", BEFORE="1" * 40) == ["my task", "t1"] + + +def _summary(tmp_path: Path, job_status: str) -> tuple[str, Path]: + """Run the tasks-check Summary step with no check.json, as after a killed check.""" + run = next(s for s in _steps() if s.get("name") == "Summary")["run"] + match = re.search(r"python - <<'PY'[^\n]*\n(.*)\nPY\s*$", run, re.S) + assert match, "the Summary step is no longer a python heredoc" + temp = tmp_path / job_status + temp.mkdir() + env = {**os.environ, "RUNNER_TEMP": str(temp), "JOB_STATUS": job_status, "TASK": "t1"} + result = subprocess.run( + [sys.executable, "-c", match.group(1)], env=env, capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + return result.stdout, temp / "repo-check" / "check" / "check.json" + + +def test_a_job_cancelled_before_the_verdict_records_a_timeout(tmp_path): + summary, check = _summary(tmp_path, "cancelled") + assert json.loads(check.read_text(encoding="utf-8"))["tasks"][0]["verdict"] == "timeout" + assert "`t1`: TIMEOUT" in summary + # a timeout cancels the job, and only always() runs a step in a cancelled job + steps = {s.get("name"): s for s in _steps()} + assert steps["Summary"]["if"] == steps["Upload the check results"]["if"] == "${{ always() }}" + + +def test_a_failed_check_without_a_verdict_invents_none(tmp_path): + summary, check = _summary(tmp_path, "failure") + assert not check.exists() and "No check.json" in summary + + +def test_the_check_results_artifact_keeps_the_stderr_files(): + upload = next(s for s in _steps() if "Upload the check results" in str(s.get("name", ""))) + paths = [ln.strip() for ln in str(upload["with"]["path"]).splitlines()] + assert "${{ runner.temp }}/repo-check/check/*/*.err" in paths diff --git a/dokimi/tests/test_repo_manifest.py b/dokimi/tests/test_repo_manifest.py index bdd81c26..5f40fcfd 100644 --- a/dokimi/tests/test_repo_manifest.py +++ b/dokimi/tests/test_repo_manifest.py @@ -187,6 +187,18 @@ def test_bad_task_is_reported(tmp_path, task_extra, needle): assert "bad" not in [t.id for t in report.tasks] +@pytest.mark.parametrize("task_id", ["my task", "tab\tid", "a,b"]) +def test_task_id_with_whitespace_or_comma_is_refused(tmp_path, task_id): + # the workflow splits its tasks, smoke-tasks and exclude-tasks inputs on whitespace and commas, + # so such an id could never be selected + root = _repo(tmp_path) + _task(root, task_id) + report = check_repo(WorkTree(root), load_manifest(WorkTree(root), "dev/evals/repo-eval.yaml")) + assert not report.ok + assert any(f"task id {task_id!r} contains whitespace or a comma" in e for e in report.errors), report.errors + assert task_id not in [t.id for t in report.tasks] + + def test_task_gate_score_and_short_base(tmp_path): root = _repo(tmp_path) _task(root, "rev", family="review", base="cf0c0a0b^", gate=[{"metric": "recall", "min": 0.75}, "format_ok"],