Compare commits

..

No commits in common. "main" and "v1.6.11" have entirely different histories.

1346 changed files with 12710 additions and 1050429 deletions

View file

@ -6,7 +6,7 @@
"plugins": [ "plugins": [
{ {
"name": "gitnexus", "name": "gitnexus",
"version": "1.6.12", "version": "1.6.11",
"source": { "source": {
"source": "local", "source": "local",
"path": "./gitnexus-claude-plugin" "path": "./gitnexus-claude-plugin"

View file

@ -11,7 +11,7 @@
"plugins": [ "plugins": [
{ {
"name": "gitnexus", "name": "gitnexus",
"version": "1.6.12", "version": "1.6.11",
"source": "./gitnexus-claude-plugin", "source": "./gitnexus-claude-plugin",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase." "description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
} }

View file

@ -34,18 +34,6 @@ Run from the project root. This parses all source files, builds the knowledge gr
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted. For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
## Index storage and retention
Default location is `<repo>/.gitnexus/`. Override with environment variables (also documented in README):
| Env | Effect |
| --- | ------ |
| `GITNEXUS_STORAGE_PATH` | One complete external index directory. Wins if both storage vars are set. |
| `GITNEXUS_STORAGE_ROOT` | Absolute root; GitNexus creates an isolated `<repo-basename>-<12-hex>/` slot per repository. |
| `GITNEXUS_CONTENT_RETENTION` | `full` (default) keeps file text; `symbol` keeps snippets; `none` keeps the graph only. |
`list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`. MCP `include_content` may still return symbol spans when retention is `symbol`.
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index. Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
### status — Check index freshness ### status — Check index freshness

View file

@ -42,7 +42,6 @@ diagnosis.
``` ```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklist ## Checklist

View file

@ -36,7 +36,6 @@ the bound repository and index freshness alongside your explanation.
``` ```
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. > If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklist ## Checklist

View file

@ -16,7 +16,6 @@ For any task involving code understanding, debugging, impact analysis, or refact
3. **Follow the skill's workflow and checklist** 3. **Follow the skill's workflow and checklist**
> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first. > If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
> On `query` / `context` / `impact` / `cypher`, read `staleness.status` and `staleness.branch`/`lastCommit` before using the answer. Re-analyze only for `behind` or `diverged`.
## Skills ## Skills
@ -84,51 +83,15 @@ Notes: `offset` ≥ `total` returns an empty page (with `total` still reported).
### Inline staleness signal (`query` / `context` / `impact` / `cypher`) ### Inline staleness signal (`query` / `context` / `impact` / `cypher`)
These four hot read tools attach a non-blocking `staleness` field to every response, in the shape `{ status, branch?, lastCommit, indexedAt, measuredAgainst, commitsBehind?, hint? }`. It answers two different questions at once: **which index answered** and **how fresh it is**. The identity half is why the field is present even when nothing is wrong — an answer computed from a branch-pinned index is otherwise indistinguishable from one computed from the default branch (#3291): These four hot read tools attach a non-blocking `staleness` field to their response when the index is behind the checkout's current HEAD — the same `{ commitsBehind, hint }` shape `list_repos` already reports — so a direct tool call surfaces a behind-HEAD index without a separate `list_repos` call:
```jsonc ```jsonc
{ /* …the tool's normal result… */ { /* …the tool's normal result… */
"staleness": { "staleness": { "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
"status": "current",
"branch": "feature/checkout-v2",
"lastCommit": "4f2a1c9e8b7d6a5c4e3f2a1b0c9d8e7f6a5b4c3d",
"indexedAt": "2026-09-15T07:12:00.000Z",
"measuredAgainst": "HEAD"
}
} }
``` ```
`status: "current"` here means *this index is at the HEAD of the clone it was built from* — not that it is current with the default branch. `measuredAgainst` names what `commitsBehind` is counted against: the checked-out HEAD of that clone, never the remote. `branch` is the branch the index represents; it is absent for a detached HEAD, a non-git folder, or a legacy index that never recorded one, so read `lastCommit` when you need an identifier that is always present. The field is **absent when the index is current** (or when the freshness check can't run), so its presence is the signal. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
When the index is behind that HEAD, the count and hint ride along:
```jsonc
{ /* …the tool's normal result… */
"staleness": {
"status": "behind", "commitsBehind": 3, "branch": "main",
"lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0",
"indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD",
"hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update."
}
}
```
`commitsBehind` is present only when git counted the gap. When git could not count it but HEAD still resolves to a commit other than the indexed one — usually because the indexed commit is no longer in the clone's history — the index is provably not at HEAD with no countable gap, so no number is reported:
```jsonc
{ /* …the tool's normal result… */
"staleness": {
"status": "diverged", "branch": "main",
"lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0",
"indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD",
"hint": "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update."
}
}
```
So: **read `status` before using `commitsBehind`**, and read `branch`/`lastCommit` before assuming which ref the answer describes. `status: "unknown"` means the freshness check could not run at all (a `--skip-git` folder has no history to measure) — the ref is still reported, because which index answered is knowable even when its freshness is not. The field is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). Re-run `analyze` only for `behind` or `diverged` — those mean the index is not at this clone's HEAD. `unknown` is unmeasurable, not stale; analyze cannot make it `current` unless git history exists.
`list_repos` and the HTTP repo routes are unchanged: they omit `staleness` entirely for a current index and report the ref through their own top-level `branch` / `lastCommit` / `indexedAt` fields.
### Taint findings (`explain`) ### Taint findings (`explain`)

View file

@ -53,7 +53,6 @@ Repository: <name> (<path>) Worktree: <path> Index: <commit>, <n> behind HEA
``` ```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
> If `.gitnexus/run.cjs` is missing, replace `node .gitnexus/run.cjs` with `npx gitnexus` in the fallback commands. > If `.gitnexus/run.cjs` is missing, replace `node .gitnexus/run.cjs` with `npx gitnexus` in the fallback commands.
## Checklist ## Checklist

View file

@ -46,7 +46,6 @@ checkout and reports nothing changed, which reads as a verified refactor.
``` ```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklists ## Checklists

View file

@ -1,19 +0,0 @@
{
"name": "gitnexus-marketplace",
"owner": {
"name": "GitNexus",
"email": "nico@gitnexus.dev"
},
"metadata": {
"description": "Code intelligence powered by a knowledge graph — execution flows, blast radius, and semantic search",
"homepage": "https://github.com/nicosxt/gitnexus"
},
"plugins": [
{
"name": "gitnexus",
"version": "1.6.12",
"source": "./gitnexus-factory-plugin",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
}
]
}

1
.gitattributes vendored
View file

@ -15,7 +15,6 @@
*.so binary *.so binary
*.dll binary *.dll binary
*.dylib binary *.dylib binary
*.lbug_extension binary
# TypeScript sources are always text for diff purposes. Git's binary # TypeScript sources are always text for diff purposes. Git's binary
# heuristic fires when EITHER blob in a pair carries a NUL, so a source # heuristic fires when EITHER blob in a pair carries a NUL, so a source

View file

@ -19,8 +19,8 @@ runs:
# Browsers are installed explicitly by e2e. Typecheck only needs types. # Browsers are installed explicitly by e2e. Typecheck only needs types.
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
# Compile shared with the web package's TypeScript 7. Do not npm-ci # Compile shared with the web package's TypeScript 5. Do not npm-ci
# gitnexus-shared (a second optional-platform install, ~7 minutes). # gitnexus-shared (TypeScript 7 optional-platform install, ~7 minutes).
- name: Build gitnexus-shared - name: Build gitnexus-shared
# node + lib/tsc.js — same on Windows/macOS/Linux. Do not use .bin/tsc # node + lib/tsc.js — same on Windows/macOS/Linux. Do not use .bin/tsc
# (tsc.cmd on Windows; execFileSync cannot launch .cmd without a shell). # (tsc.cmd on Windows; execFileSync cannot launch .cmd without a shell).

View file

@ -89,13 +89,6 @@ updates:
# tree-sitter-cli follows the runtime's version cadence. Bump when # tree-sitter-cli follows the runtime's version cadence. Bump when
# regenerating vendor/tree-sitter-proto/src/parser.c, not on a schedule. # regenerating vendor/tree-sitter-proto/src/parser.c, not on a schedule.
- dependency-name: tree-sitter-cli - dependency-name: tree-sitter-cli
# Pin @ladybugdb/core so a daily bump cannot ship a skewed FTS artifact.
# The extension version is a separate upstream constant, not derivable
# from the core version (see vendor/lbug-fts/manifest.json).
- dependency-name: '@ladybugdb/core'
- dependency-name: typescript
update-types:
- version-update:semver-major
# gitnexus-web (thin frontend client). # gitnexus-web (thin frontend client).
- package-ecosystem: npm - package-ecosystem: npm
@ -114,10 +107,6 @@ updates:
labels: labels:
- dependencies - dependencies
- frontend - frontend
ignore:
- dependency-name: typescript
update-types:
- version-update:semver-major
# Shared types package. # Shared types package.
- package-ecosystem: npm - package-ecosystem: npm
@ -135,9 +124,3 @@ updates:
include: scope include: scope
labels: labels:
- dependencies - dependencies
ignore:
# Keep shared on the same TypeScript major as CLI/web. A shared-only
# major bump reintroduces a compiler split this repo unified.
- dependency-name: typescript
update-types:
- version-update:semver-major

View file

@ -68,9 +68,7 @@ GRAMMARS: dict[str, tuple[str, str, str]] = {
"tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"), "tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"),
# Vendored parsers — kept here so the upstream coords for drift # Vendored parsers — kept here so the upstream coords for drift
# detection are co-located with every other grammar's coords. # detection are co-located with every other grammar's coords.
"tree-sitter-objc": ("tree-sitter-grammars/tree-sitter-objc", "master", "src/parser.c"),
"tree-sitter-proto": ("coder3101/tree-sitter-proto", "main", "src/parser.c"), "tree-sitter-proto": ("coder3101/tree-sitter-proto", "main", "src/parser.c"),
"tree-sitter-zig": ("tree-sitter-grammars/tree-sitter-zig", "master", "src/parser.c"),
} }
# npm-installed grammars deliberately held below npm latest (surfaced so reviewers # npm-installed grammars deliberately held below npm latest (surfaced so reviewers

View file

@ -1,144 +0,0 @@
#!/usr/bin/env node
/**
* Fetch Ladybug FTS artifacts into gitnexus/vendor/lbug-fts/prebuilds/.
*
* Lives outside the published package (`files` includes `scripts` wholesale).
* Reads versions, filename, and tuple→upstream-platform mapping from
* vendor/lbug-fts/manifest.json so the gate and runtime cannot drift.
*
* Usage: node .github/scripts/fetch-lbug-fts-artifacts.mjs
*/
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const VENDOR = path.join(REPO_ROOT, 'gitnexus', 'vendor', 'lbug-fts');
const PREBUILDS = path.join(VENDOR, 'prebuilds');
const MANIFEST_PATH = path.join(VENDOR, 'manifest.json');
/** Only the Ladybug official extension host — never a manifest-supplied origin. */
const OFFICIAL_REPO = 'https://extension.ladybugdb.com/';
const EXACT_VERSION = /^\d+\.\d+\.\d+$/;
const SAFE_UPSTREAM = /^(linux_amd64|linux_arm64|osx_amd64|osx_arm64|win_amd64)$/;
/**
* Build the official artifact URL from allowlisted fields only.
* `officialRepo` in the manifest must match {@link OFFICIAL_REPO}; the
* origin itself is a constant so an edited manifest cannot redirect the fetch.
*/
export function officialArtifactUrl(manifest, upstreamPlatform) {
const officialRepo = String(manifest?.officialRepo ?? '');
if (officialRepo !== OFFICIAL_REPO) {
throw new Error(`refusing unofficial FTS repo: '${officialRepo}'`);
}
const version = String(manifest?.extensionVersion ?? '');
if (!EXACT_VERSION.test(version)) {
throw new Error(`unsafe extensionVersion: '${version}'`);
}
if (!SAFE_UPSTREAM.test(String(upstreamPlatform ?? ''))) {
throw new Error(`unsafe upstream platform: '${upstreamPlatform}'`);
}
const filename = String(manifest?.filename ?? '');
if (!SAFE_FILENAME.test(filename)) {
throw new Error(`unsafe FTS artifact filename: '${filename}'`);
}
return `${OFFICIAL_REPO}v${version}/${upstreamPlatform}/fts/${filename}`;
}
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
const readExistingHash = (filePath) => {
if (!existsSync(filePath)) return null;
return sha256(readFileSync(filePath));
};
export const supportedTuples = (manifest) => manifest.tuples.map((entry) => entry.tuple);
const SAFE_TUPLE = /^(darwin|linux|win32)-(x64|arm64)$/;
const SAFE_FILENAME = /^[\w.-]+\.lbug_extension$/;
/** Relative-path containment — not a prefix match (rejects `prebuilds-evil`). */
const isPathInsideRoot = (root, candidate) => {
const relative = path.relative(root, candidate);
if (path.isAbsolute(relative)) return false;
return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..';
};
export function assertSafeArtifactDest({ prebuildsDir, tuple, filename }) {
if (!SAFE_TUPLE.test(String(tuple ?? ''))) {
throw new Error(
`unsafe FTS artifact tuple: '${tuple}' (expected (darwin|linux|win32)-(x64|arm64))`,
);
}
if (!SAFE_FILENAME.test(String(filename ?? ''))) {
throw new Error(`unsafe FTS artifact filename: '${filename}' (expected *.lbug_extension)`);
}
const dest = path.join(prebuildsDir, tuple, filename);
if (!isPathInsideRoot(prebuildsDir, dest)) {
throw new Error(`FTS artifact dest is not inside prebuildsDir: ${dest}`);
}
return dest;
}
async function fetchBuffer(url) {
// codeql[js/request-forgery] — origin is OFFICIAL_REPO; path segments are allowlisted.
// lgtm[js/request-forgery]
// codeql[js/file-access-to-http] — versions/platforms are regex-pinned, not raw file bytes.
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
if (!res.ok) {
throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
}
return Buffer.from(await res.arrayBuffer());
}
const writeAllowlistedArtifact = (prebuildsDir, dest, buf) => {
if (!isPathInsideRoot(prebuildsDir, dest)) {
throw new Error(`FTS artifact dest is not inside prebuildsDir: ${dest}`);
}
// codeql[js/http-to-file-access] — dest is assertSafeArtifactDest + containment-checked.
writeFileSync(dest, buf);
};
export async function refreshArtifacts({
manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')),
prebuildsDir = PREBUILDS,
download = fetchBuffer,
} = {}) {
mkdirSync(prebuildsDir, { recursive: true });
const lines = [];
for (const { tuple, upstreamPlatform } of manifest.tuples) {
const dest = assertSafeArtifactDest({
prebuildsDir,
tuple,
filename: manifest.filename,
});
mkdirSync(path.dirname(dest), { recursive: true });
const url = officialArtifactUrl(manifest, upstreamPlatform);
const previousHash = readExistingHash(dest);
const previousSize = previousHash ? readFileSync(dest).byteLength : 0;
const buf = await download(url);
const nextHash = sha256(buf);
writeAllowlistedArtifact(prebuildsDir, dest, buf);
const changed = previousHash !== nextHash;
console.log(
changed
? `[fts-fetch] ${tuple}: ${previousHash ?? '(new)'} (${previousSize} B) → ${nextHash} (${buf.byteLength} B)`
: `[fts-fetch] ${tuple}: unchanged ${nextHash} (${buf.byteLength} B)`,
);
lines.push(`${nextHash} ./${tuple}/${manifest.filename}`);
}
lines.sort();
writeFileSync(path.join(prebuildsDir, 'SHA256SUMS'), `${lines.join('\n')}\n`);
return lines;
}
const invokedDirectly =
process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (invokedDirectly) {
refreshArtifacts().catch((err) => {
console.error(`[fts-fetch] ${err instanceof Error ? err.message : err}`);
process.exit(1);
});
}

View file

@ -9,8 +9,8 @@ which is deliberately dependency-free so it runs on any vanilla runner. Run with
(pytest also discovers ``unittest.TestCase`` classes, so a future pytest CI job (pytest also discovers ``unittest.TestCase`` classes, so a future pytest CI job
picks these up unchanged.) picks these up unchanged.)
These tests lock in the #858 fix: the 7 vendored grammars These tests lock in the #858 fix: the 5 vendored grammars
(c/swift/kotlin/dart/objc/proto/zig) are classified from the shared manifest (c/swift/kotlin/dart/proto) are classified from the shared manifest
(.github/vendored-grammars.json), their ABI is read from gitnexus/vendor/<name>, (.github/vendored-grammars.json), their ABI is read from gitnexus/vendor/<name>,
and the report never renders a bare ``?`` placeholder. All network is mocked. and the report never renders a bare ``?`` placeholder. All network is mocked.
""" """
@ -192,7 +192,7 @@ class AssertCurrent(TestCase):
def test_assert_current_is_network_free_and_passes(self): def test_assert_current_is_network_free_and_passes(self):
report, code = self._run_assert_current() # raises if any urlopen fires report, code = self._run_assert_current() # raises if any urlopen fires
self.assertEqual(code, 0) self.assertEqual(code, 0)
# All 7 vendored grammars are introspected from the repo (ABI 14), not skipped. # All 5 vendored grammars are introspected from the repo (ABI 14), not skipped.
for name in readiness.VENDORED_NAMES: for name in readiness.VENDORED_NAMES:
self.assertIn(f"{name}: vendored ABI", report) self.assertIn(f"{name}: vendored ABI", report)
@ -324,7 +324,6 @@ class ReportRendering(TestCase):
# which is what removes the old "? (fetch failed)" for tree-sitter-proto. # which is what removes the old "? (fetch failed)" for tree-sitter-proto.
self.assertNotIn("tree-sitter-proto", _render_report.last_npm_calls) self.assertNotIn("tree-sitter-proto", _render_report.last_npm_calls)
self.assertNotIn("tree-sitter-dart", _render_report.last_npm_calls) self.assertNotIn("tree-sitter-dart", _render_report.last_npm_calls)
self.assertNotIn("@tree-sitter-grammars/tree-sitter-zig", _render_report.last_npm_calls)
self.assertNotIn("Could not check", self.report) self.assertNotIn("Could not check", self.report)
self.assertNotIn("fetch failed", self.report) self.assertNotIn("fetch failed", self.report)
@ -344,11 +343,11 @@ class ReportRendering(TestCase):
cells = [c.strip() for c in self._matrix_row("tree-sitter-swift").strip().strip("|").split("|")] cells = [c.strip() for c in self._matrix_row("tree-sitter-swift").strip().strip("|").split("|")]
self.assertEqual(cells[6], "n/a") # Upstream ABI column self.assertEqual(cells[6], "n/a") # Upstream ABI column
def test_row_diff_regex_captures_all_grammar_statuses(self): def test_row_diff_regex_captures_all_fifteen_grammar_statuses(self):
# The change-detection bot keys on this regex: group 1 = grammar name, # The change-detection bot keys on this regex: group 1 = grammar name,
# group 2 = the Status cell ONLY (not the whole tail). It must match every # group 2 = the Status cell ONLY (not the whole tail). It must match every
# row after the format change so status transitions keep being detected. # row after the format change so status transitions keep being detected.
self.assertEqual(len(self.rows), len(readiness.GRAMMARS)) self.assertEqual(len(self.rows), 15)
for name in readiness.VENDORED_NAMES: for name in readiness.VENDORED_NAMES:
self.assertIn(name, self.rows) self.assertIn(name, self.rows)
# group 2 is the Status cell — held c renders exactly "Vendored — held", # group 2 is the Status cell — held c renders exactly "Vendored — held",
@ -365,16 +364,15 @@ class ReportRendering(TestCase):
# Counts are derived from _render_report()'s mock corpus (all npm peer # Counts are derived from _render_report()'s mock corpus (all npm peer
# deps mocked permissive): of the 10 npm-installed grammars, 9 render # deps mocked permissive): of the 10 npm-installed grammars, 9 render
# Ready and 1 — tree-sitter-cpp — is the intentional pin (#1242), so it is # Ready and 1 — tree-sitter-cpp — is the intentional pin (#1242), so it is
# not counted ready. The 4 blockers are that same pinned tree-sitter-cpp # not counted ready. The 3 blockers are that same pinned tree-sitter-cpp
# plus three held vendored grammars: ABI-held tree-sitter-c (#1242/#858), # plus two held vendored grammars: ABI-held tree-sitter-c (#1242/#858) and
# tree-sitter-kotlin (pinned to an unreleased fwcd main commit for `fun # tree-sitter-kotlin (pinned to an unreleased fwcd main commit for `fun
# interface` support — ABI 14 is in range, but a hold counts as a blocker # interface` support — ABI 14 is in range, but a hold counts as a blocker
# until it is lifted), and tree-sitter-objc. If a grammar is added/removed # until it is lifted). If a grammar is added/removed or a pin/hold changes,
# or a pin/hold changes,
# update _render_report()'s mock AND these expected counts together; a # update _render_report()'s mock AND these expected counts together; a
# mismatch here means the report prose drifted, not the regex. # mismatch here means the report prose drifted, not the regex.
self.assertEqual(ready.groups(), ("9", "10")) self.assertEqual(ready.groups(), ("9", "10"))
self.assertEqual(blockers.group(1), "4") self.assertEqual(blockers.group(1), "3")
def _matrix_row(self, name: str) -> str: def _matrix_row(self, name: str) -> str:
for line in self.report.splitlines(): for line in self.report.splitlines():

View file

@ -21,7 +21,7 @@
* node update-vendored-grammars.mjs # detect only → JSON report on stdout * node update-vendored-grammars.mjs # detect only → JSON report on stdout
* node update-vendored-grammars.mjs --apply X # re-vendor grammar X in place * node update-vendored-grammars.mjs --apply X # re-vendor grammar X in place
* *
* tree-sitter-c and tree-sitter-objc are MONITORED but report-only (`hold`): c is ABI-pinned at 0.21.4 * tree-sitter-c is MONITORED but report-only (`hold`): it is ABI-pinned at 0.21.4
* (#1242/#858) and must not auto-bump without a tree-sitter runtime upgrade, so an * (#1242/#858) and must not auto-bump without a tree-sitter runtime upgrade, so an
* available c update is detected + reported but never auto-applied — even if it is * available c update is detected + reported but never auto-applied — even if it is
* ABI-13/14. A maintainer re-vendors it deliberately. * ABI-13/14. A maintainer re-vendors it deliberately.

View file

@ -1,274 +0,0 @@
// Resolve the open PR for a trusted workflow_run consumer.
//
// Shared by commit-fork-prebuilds.yml and pr-autofix-publish.yml.
// workflow_run.pull_requests[] is empty on fork PRs, and
// GET /repos/{base}/commits/{sha}/pulls is also empty because the fork head
// commit is not in the base repo's commit graph. The authoritative lookup is
// GET /repos/{base}/pulls?head={owner}:{branch}&state=open using
// workflow_run.head_repository + workflow_run.head_branch (server-controlled).
// That same query works for same-repo PRs (owner is the base repo owner).
//
// The current PR tip may have moved past the SHA the producer built; that is
// not an identity failure — the caller decides whether to lease-push or just
// comment. Two open PRs from the same fork head (same owner:branch into this
// repo) are an identity failure: artifact pr_number is untrusted and must not
// pick among them. Set SCHEMA_PATTERN to the artifact schema allowlist
// (defaults to the tree-sitter prebuild schema).
'use strict';
const fs = require('node:fs');
const { spawnSync } = require('node:child_process');
const SCHEMA_PATTERN = /^gitnexus\.ts-prebuild\/v[0-9]+$/;
const IDENTITY_PATTERNS = {
pr_number: /^[0-9]+$/,
head_sha: /^[0-9a-f]{40}$/,
head_ref: /^[A-Za-z0-9._/-]+$/,
repo: /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/,
};
function allowlistField(key, value, pattern) {
const text = value == null ? '' : String(value);
if (!text || !pattern.test(text)) {
throw new Error(`metadata.${key} failed allowlist (got: ${JSON.stringify(text)})`);
}
return text;
}
function forkHeadOwner(headRepo) {
const slash = headRepo.indexOf('/');
if (slash <= 0 || slash === headRepo.length - 1) {
throw new Error(`head_repo must be owner/name (got: ${JSON.stringify(headRepo)})`);
}
return headRepo.slice(0, slash);
}
function compileSchemaPattern(value) {
if (value instanceof RegExp) return value;
if (typeof value === 'string' && value.length > 0) {
try {
return new RegExp(value);
} catch {
throw new Error('SCHEMA_PATTERN is not a valid regular expression');
}
}
return SCHEMA_PATTERN;
}
function allowlistMetadata(raw, schemaPattern) {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('metadata.json must be an object');
}
return {
schema: allowlistField('schema', parsed.schema, compileSchemaPattern(schemaPattern)),
pr_number: allowlistField('pr_number', parsed.pr_number, IDENTITY_PATTERNS.pr_number),
head_sha: allowlistField('head_sha', parsed.head_sha, IDENTITY_PATTERNS.head_sha),
head_ref: allowlistField('head_ref', parsed.head_ref, IDENTITY_PATTERNS.head_ref),
head_repo: allowlistField('head_repo', parsed.head_repo, IDENTITY_PATTERNS.repo),
base_repo: allowlistField('base_repo', parsed.base_repo, IDENTITY_PATTERNS.repo),
};
}
function allowlistAuthority(authority) {
return {
head_sha: allowlistField('head_sha', authority.head_sha, IDENTITY_PATTERNS.head_sha),
head_repo: allowlistField('head_repo', authority.head_repo, IDENTITY_PATTERNS.repo),
head_branch: allowlistField('head_ref', authority.head_branch, IDENTITY_PATTERNS.head_ref),
base_repo: allowlistField('base_repo', authority.base_repo, IDENTITY_PATTERNS.repo),
};
}
function verifyArtifactAgainstWorkflowRun(meta, authority) {
if (meta.head_sha !== authority.head_sha) {
throw new Error(
`Artifact head_sha (${meta.head_sha}) != workflow_run.head_sha (${authority.head_sha}) — refusing.`,
);
}
if (meta.head_repo !== authority.head_repo) {
throw new Error(
`Artifact head_repo (${meta.head_repo}) != workflow_run.head_repository (${authority.head_repo}) — refusing.`,
);
}
if (meta.base_repo !== authority.base_repo) {
throw new Error('Artifact base_repo does not match $GITHUB_REPOSITORY — refusing.');
}
if (meta.head_ref !== authority.head_branch) {
throw new Error(
`Artifact head_ref (${meta.head_ref}) != workflow_run.head_branch (${authority.head_branch}) — refusing.`,
);
}
}
function matchOpenPullsFromForkHead(pulls, { headRepo, headBranch, baseRepo }) {
if (!Array.isArray(pulls)) {
throw new Error('GitHub pulls?head= lookup returned a non-array');
}
return pulls.filter((pr) => {
return (
pr &&
pr.state === 'open' &&
Number.isInteger(pr.number) &&
pr.head &&
pr.head.repo &&
pr.head.repo.full_name === headRepo &&
pr.head.ref === headBranch &&
pr.base &&
pr.base.repo &&
pr.base.repo.full_name === baseRepo
);
});
}
function resolveVerifiedPullRequest({ meta, authority, pulls, schemaPattern }) {
const cleanMeta = allowlistMetadata(meta, schemaPattern);
const cleanAuthority = allowlistAuthority(authority);
verifyArtifactAgainstWorkflowRun(cleanMeta, cleanAuthority);
const matched = matchOpenPullsFromForkHead(pulls, {
headRepo: cleanAuthority.head_repo,
headBranch: cleanAuthority.head_branch,
baseRepo: cleanAuthority.base_repo,
});
if (matched.length === 0) {
throw new Error(
`No open PR from ${cleanAuthority.head_repo}:${cleanAuthority.head_branch} targeting ${cleanAuthority.base_repo} — refusing.`,
);
}
// Artifact pr_number is untrusted. Do not use it to pick among several open
// PRs that share this fork head (same owner:branch into this repo, different
// base branches). Fail closed unless GitHub-controlled fields leave exactly one.
if (matched.length !== 1) {
throw new Error(
`Ambiguous open PRs from ${cleanAuthority.head_repo}:${cleanAuthority.head_branch} targeting ${cleanAuthority.base_repo} (${matched
.map((pr) => pr.number)
.join(',')}) — refusing.`,
);
}
const chosen = matched[0];
const expected = Number(cleanMeta.pr_number);
if (chosen.number !== expected) {
throw new Error(
`Artifact pr_number (${cleanMeta.pr_number}) is not the open PR(s) from this fork head (${chosen.number}) — refusing.`,
);
}
const currentHeadSha = typeof chosen.head.sha === 'string' ? chosen.head.sha : '';
return {
pr_number: String(chosen.number),
head_ref: cleanAuthority.head_branch,
head_sha: cleanAuthority.head_sha,
head_repo: cleanAuthority.head_repo,
current_head_sha: currentHeadSha,
branch_moved: Boolean(currentHeadSha && currentHeadSha !== cleanAuthority.head_sha),
};
}
function flattenGhListPages(parsed) {
if (!Array.isArray(parsed)) {
throw new Error('GitHub pulls?head= lookup returned a non-array');
}
if (parsed.length === 0) return parsed;
if (parsed.every((page) => Array.isArray(page))) {
return parsed.flat();
}
return parsed;
}
function listOpenPullsByHead({ ghRepo, headOwner, headBranch, runGh }) {
const run = runGh || ((args) => spawnSync('gh', args, { encoding: 'utf8' }));
const result = run([
'api',
'--paginate',
'--slurp',
'-X',
'GET',
`repos/${ghRepo}/pulls`,
'-f',
'state=open',
'-f',
`head=${headOwner}:${headBranch}`,
]);
if (result.status !== 0) {
const err = (result.stderr || result.stdout || '').trim();
throw new Error(`GitHub pulls?head= lookup failed: ${err || `exit ${result.status}`}`);
}
const stdout = (result.stdout || '').trim();
if (!stdout) {
throw new Error('GitHub pulls?head= lookup returned an empty body');
}
let parsed;
try {
parsed = JSON.parse(stdout);
} catch {
throw new Error('GitHub pulls?head= lookup returned non-JSON');
}
return flattenGhListPages(parsed);
}
function main() {
const schemaPattern = compileSchemaPattern(process.env.SCHEMA_PATTERN);
const raw = fs.readFileSync(process.env.META_PATH, 'utf8');
const meta = allowlistMetadata(raw, schemaPattern);
const authority = allowlistAuthority({
head_sha: process.env.WF_HEAD_SHA,
head_repo: process.env.WF_HEAD_REPO,
head_branch: process.env.WF_HEAD_BRANCH,
base_repo: process.env.GH_REPO,
});
const pulls = listOpenPullsByHead({
ghRepo: authority.base_repo,
headOwner: forkHeadOwner(authority.head_repo),
headBranch: authority.head_branch,
});
const verified = resolveVerifiedPullRequest({ meta, authority, pulls, schemaPattern });
if (verified.branch_moved) {
console.log(
`PR head moved to ${verified.current_head_sha}; delivering against built SHA ${verified.head_sha} (lease will refuse if the branch moved).`,
);
}
console.log(
`Verified identity: PR=${verified.pr_number} head_sha=${verified.head_sha} head_repo=${verified.head_repo} head_ref=${verified.head_ref}.`,
);
const out = process.env.GITHUB_OUTPUT;
if (!out) {
throw new Error('GITHUB_OUTPUT is unset');
}
fs.appendFileSync(
out,
[
`pr_number=${verified.pr_number}`,
`head_ref=${verified.head_ref}`,
`head_sha=${verified.head_sha}`,
`head_repo=${verified.head_repo}`,
].join('\n') + '\n',
);
}
if (require.main === module) {
try {
main();
} catch (err) {
console.error(`::error::${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
module.exports = {
SCHEMA_PATTERN,
IDENTITY_PATTERNS,
allowlistField,
compileSchemaPattern,
allowlistMetadata,
allowlistAuthority,
forkHeadOwner,
verifyArtifactAgainstWorkflowRun,
matchOpenPullsFromForkHead,
flattenGhListPages,
resolveVerifiedPullRequest,
listOpenPullsByHead,
main,
};

View file

@ -6,11 +6,6 @@
"upstream": { "npm": "tree-sitter-c" }, "upstream": { "npm": "tree-sitter-c" },
"hold": "ABI-pinned at 0.21.4 (#1242/#858) — needs a tree-sitter runtime upgrade before bumping" "hold": "ABI-pinned at 0.21.4 (#1242/#858) — needs a tree-sitter runtime upgrade before bumping"
}, },
"objc": {
"name": "tree-sitter-objc",
"upstream": { "npm": "tree-sitter-objc" },
"hold": "Pinned at 3.0.2 for the Objective-C provider MVP; carries darwin/linux arm64+x64 prebuilds compatible with the current tree-sitter runtime (linux-arm64 built from vendored source because the upstream npm artifact is mislabeled)"
},
"swift": { "swift": {
"name": "tree-sitter-swift", "name": "tree-sitter-swift",
"upstream": { "npm": "tree-sitter-swift" } "upstream": { "npm": "tree-sitter-swift" }
@ -27,10 +22,6 @@
"proto": { "proto": {
"name": "tree-sitter-proto", "name": "tree-sitter-proto",
"upstream": { "github": "coder3101/tree-sitter-proto" } "upstream": { "github": "coder3101/tree-sitter-proto" }
},
"zig": {
"name": "tree-sitter-zig",
"upstream": { "npm": "@tree-sitter-grammars/tree-sitter-zig" }
} }
} }
} }

View file

@ -7,7 +7,7 @@ name: Build tree-sitter prebuilds
# #
# Grammars covered here (the at-risk set — everything else already ships 6 # Grammars covered here (the at-risk set — everything else already ships 6
# upstream prebuilds AND stays dependency-review-tracked, so it is left alone). # upstream prebuilds AND stays dependency-review-tracked, so it is left alone).
# All seven are vendored under gitnexus/vendor/; `kind` (below) only picks where # All five are vendored under gitnexus/vendor/; `kind` (below) only picks where
# the build job fetches the C source to compile: # the build job fetches the C source to compile:
# - tree-sitter-c (vendored prebuild-only; built from the published npm # - tree-sitter-c (vendored prebuild-only; built from the published npm
# package — closes upstream's 4/6 ARM gap #2116 for a # package — closes upstream's 4/6 ARM gap #2116 for a
@ -17,22 +17,15 @@ name: Build tree-sitter prebuilds
# - tree-sitter-kotlin (vendored source; built from gitnexus/vendor/ — pinned to # - tree-sitter-kotlin (vendored source; built from gitnexus/vendor/ — pinned to
# an unreleased main commit for `fun interface` support # an unreleased main commit for `fun interface` support
# (#169) that no npm release carries yet) # (#169) that no npm release carries yet)
# - tree-sitter-objc (vendored source; built from gitnexus/vendor/ — pinned
# for the Objective-C provider MVP)
# - tree-sitter-swift (vendored source; built from gitnexus/vendor/ — its # - tree-sitter-swift (vendored source; built from gitnexus/vendor/ — its
# prebuilds were originally upstream-shipped, now # prebuilds were originally upstream-shipped, now
# GitNexus-cross-built like the rest for uniformity) # GitNexus-cross-built like the rest for uniformity)
# - tree-sitter-zig (vendored source; built from gitnexus/vendor/ — moved
# off npm optionalDependency so `npm i -g gitnexus`
# no longer warns on peerOptional tree-sitter@^0.22.1.
# Upstream linux-arm64 prebuild is a mispackaged
# x86-64 binary; this workflow rebuilds all seven.)
# #
# Output: gitnexus/vendor/<grammar>/prebuilds/<platform-arch>/<grammar>.node for # Output: gitnexus/vendor/<grammar>/prebuilds/<platform-arch>/<grammar>.node for
# all 6 targets ({linux,darwin,win32}-{x64,arm64}). tree-sitter grammars are # all 6 targets ({linux,darwin,win32}-{x64,arm64}). tree-sitter grammars are
# N-API, so one ABI-stable .node per platform-arch works across all Node majors. # N-API, so one ABI-stable .node per platform-arch works across all Node majors.
# #
# COST DISCIPLINE — this is a HEAVY native matrix (up to 7 grammars x 6 runners, # COST DISCIPLINE — this is a HEAVY native matrix (up to 3 grammars x 6 runners,
# incl. macOS + arm64). It is DELIBERATELY NOT wired into normal PR/push CI. It # incl. macOS + arm64). It is DELIBERATELY NOT wired into normal PR/push CI. It
# runs only: # runs only:
# 1. on manual dispatch (workflow_dispatch); or # 1. on manual dispatch (workflow_dispatch); or
@ -40,9 +33,8 @@ name: Build tree-sitter prebuilds
# OR an edit to the grammar's build-affecting source (parser.c / grammar.js / # OR an edit to the grammar's build-affecting source (parser.c / grammar.js /
# binding.gyp / scanner / bindings). The `guard` job is the real gate (it # binding.gyp / scanner / bindings). The `guard` job is the real gate (it
# diffs BOTH the recorded version AND the source files vs the PR base); the # diffs BOTH the recorded version AND the source files vs the PR base); the
# `paths:` filter below keeps ordinary code PRs at ZERO matrix time. PR # `paths:` filter below keeps ordinary code PRs at ZERO matrix time and
# filters see the cumulative diff, so the guard separately skips updates # excludes the prebuilds the job commits back, so it never retriggers itself.
# containing only the prebuilds the job commits back.
# Net effect: an ordinary code PR triggers nothing; touching one grammar's source # Net effect: an ordinary code PR triggers nothing; touching one grammar's source
# costs exactly one matrix run for that grammar. Delivery of the rebuilt binaries: # costs exactly one matrix run for that grammar. Delivery of the rebuilt binaries:
# - same-repo PR -> committed straight onto the PR's own branch (in the SAME PR); # - same-repo PR -> committed straight onto the PR's own branch (in the SAME PR);
@ -63,7 +55,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
grammars: grammars:
description: 'Comma-separated grammar shortnames to build (c,dart,proto,kotlin,objc,swift,zig), or "all".' description: 'Comma-separated grammar shortnames to build (c,dart,proto,kotlin,swift), or "all".'
required: false required: false
type: string type: string
default: 'all' default: 'all'
@ -88,14 +80,13 @@ on:
# Any build-affecting change under a vendored grammar triggers a rebuild — # Any build-affecting change under a vendored grammar triggers a rebuild —
# not just a version bump — so editing the vendored source (parser.c, # not just a version bump — so editing the vendored source (parser.c,
# grammar.js, binding.gyp, scanner, bindings) re-cuts the prebuilds too. # grammar.js, binding.gyp, scanner, bindings) re-cuts the prebuilds too.
# Excludes PRs containing only prebuilds. A source PR still matches after a # The prebuilds we commit back are EXCLUDED (negated last) so the bot's own
# bot commit because PR filters use the cumulative diff; `guard` stops the # in-PR commit can never retrigger this workflow (no build->commit->build loop).
# build->commit->build loop using the synchronize event's before/head diff.
- 'gitnexus/vendor/tree-sitter-*/**' - 'gitnexus/vendor/tree-sitter-*/**'
- '!gitnexus/vendor/tree-sitter-*/prebuilds/**' - '!gitnexus/vendor/tree-sitter-*/prebuilds/**'
# Self-test: re-run the guard if a future grammar pin is reintroduced in # Self-test: re-run the guard if a future grammar pin is reintroduced in
# the main package.json (optionalDependencies fallback). No-op otherwise — # the main package.json (optionalDependencies fallback). No-op otherwise —
# all seven grammars are now fully vendored (including kotlin, objc, and zig). # all five grammars are now fully vendored (kotlin included).
- 'gitnexus/package.json' - 'gitnexus/package.json'
# Self-test: re-run the guard (normally a no-op) when the recipe changes. # Self-test: re-run the guard (normally a no-op) when the recipe changes.
- '.github/workflows/build-tree-sitter-prebuilds.yml' - '.github/workflows/build-tree-sitter-prebuilds.yml'
@ -123,7 +114,7 @@ jobs:
matrix: ${{ steps.decide.outputs.matrix }} matrix: ${{ steps.decide.outputs.matrix }}
release_app: ${{ steps.relapp.outputs.configured }} release_app: ${{ steps.relapp.outputs.configured }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 # need base history to diff recorded versions fetch-depth: 0 # need base history to diff recorded versions
persist-credentials: false persist-credentials: false
@ -132,9 +123,6 @@ jobs:
id: decide id: decide
env: env:
EVENT: ${{ github.event_name }} EVENT: ${{ github.event_name }}
ACTION: ${{ github.event.action }}
BEFORE_SHA: ${{ github.event.before }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
# Untrusted dispatch inputs — read via env only, validated in JS. # Untrusted dispatch inputs — read via env only, validated in JS.
INPUT_GRAMMARS: ${{ inputs.grammars }} INPUT_GRAMMARS: ${{ inputs.grammars }}
INPUT_REF: ${{ inputs.ref }} INPUT_REF: ${{ inputs.ref }}
@ -143,7 +131,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
node --input-type=module - <<'NODE' node --input-type=module - <<'NODE'
import { execFileSync, execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import { appendFileSync } from 'node:fs'; import { appendFileSync } from 'node:fs';
@ -165,18 +153,10 @@ jobs:
// unreleased main commit for `fun interface` support (#169) that no // unreleased main commit for `fun interface` support (#169) that no
// npm release carries yet — so it must build from the vendored source. // npm release carries yet — so it must build from the vendored source.
kotlin: { name: 'tree-sitter-kotlin', kind: 'vendored' }, kotlin: { name: 'tree-sitter-kotlin', kind: 'vendored' },
// Objective-C is vendored WITH its source and its native bindings
// must be recut together with the pinned grammar snapshot.
objc: { name: 'tree-sitter-objc', kind: 'vendored' },
// swift is vendored WITH its source (parser.c/scanner.c/binding.gyp), // swift is vendored WITH its source (parser.c/scanner.c/binding.gyp),
// so it builds from gitnexus/vendor/ like dart/proto. Its prebuilds // so it builds from gitnexus/vendor/ like dart/proto. Its prebuilds
// were originally upstream-shipped; rebuilding them here unifies it. // were originally upstream-shipped; rebuilding them here unifies it.
swift: { name: 'tree-sitter-swift', kind: 'vendored' }, swift: { name: 'tree-sitter-swift', kind: 'vendored' },
// zig is vendored WITH its source (parser.c/binding.gyp). Moved off
// the npm optionalDependency so published installs no longer warn
// on peerOptional tree-sitter@^0.22.1. Upstream linux-arm64
// prebuild is a mispackaged x86-64 binary; rebuild here.
zig: { name: 'tree-sitter-zig', kind: 'vendored' },
}; };
const PLATFORMS = [ const PLATFORMS = [
{ platform_arch: 'linux-x64', os: 'ubuntu-24.04' }, { platform_arch: 'linux-x64', os: 'ubuntu-24.04' },
@ -207,30 +187,6 @@ jobs:
const event = process.env.EVENT; const event = process.env.EVENT;
const force = process.env.FORCE === 'true'; const force = process.env.FORCE === 'true';
// PR path filters and the source/version checks below see the entire
// PR, so excluding prebuilds there does NOT prevent a rebuild loop.
// Check the whole push (not HEAD^ or the author's identity): a push
// containing source edits followed by a binary commit must still build.
if (event === 'pull_request' && process.env.ACTION === 'synchronize') {
const before = process.env.BEFORE_SHA;
const head = process.env.HEAD_SHA;
for (const sha of [before, head]) {
if (!sha || !/^[0-9a-fA-F]{40}$/.test(sha)) {
throw new Error('synchronize requires valid before/head SHAs; refusing an unbounded rebuild');
}
}
// Fail closed if either commit is unavailable. Never fall back to
// the cumulative PR diff, which would re-enable the loop.
const changed = execFileSync('git', [
'diff', '--name-only', '--no-renames', '-z', before, head, '--',
], { encoding: 'utf8' }).split('\0').filter(Boolean);
if (changed.every((p) => /^gitnexus\/vendor\/tree-sitter-[^/]+\/prebuilds\//.test(p))) {
appendFileSync(process.env.GITHUB_OUTPUT, 'any=false\nmatrix={"include":[]}\n');
console.log('::notice::Push changes only prebuild outputs (or no files) — skipping native matrix.');
process.exit(0);
}
}
// Select which grammar shortnames are in play. // Select which grammar shortnames are in play.
let selected; let selected;
if (event === 'workflow_dispatch') { if (event === 'workflow_dispatch') {
@ -287,9 +243,9 @@ jobs:
} else { } else {
// pull_request: build when the recorded version changed OR any // pull_request: build when the recorded version changed OR any
// build-affecting source file under the vendored grammar changed vs // build-affecting source file under the vendored grammar changed vs
// the PR base. Exclude generated outputs from build inputs; the // the PR base. The prebuilds/ subtree is excluded from the diff so
// synchronize check above prevents rebuilding the original source // the bot's own in-PR commit (which adds ONLY prebuilds) never reads
// change after every generated-prebuild commit. // as a source change — this is the other half of the no-loop guard.
const base = recordedVersion(baseRoot, name); const base = recordedVersion(baseRoot, name);
const versionChanged = !!head && head !== base; const versionChanged = !!head && head !== base;
let sourceChanged = false; let sourceChanged = false;
@ -392,7 +348,7 @@ jobs:
# and compiling them under emulation on the arm runners is slow. # and compiling them under emulation on the arm runners is slow.
timeout-minutes: 45 timeout-minutes: 45
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false # this job uploads artifacts (artipacked) persist-credentials: false # this job uploads artifacts (artipacked)
@ -510,16 +466,12 @@ jobs:
dart: "void main() { print(\"hi\"); }", dart: "void main() { print(\"hi\"); }",
proto: "syntax = \"proto3\";\nmessage M { int32 id = 1; }", proto: "syntax = \"proto3\";\nmessage M { int32 id = 1; }",
kotlin: "fun main() { println(\"hi\") }", kotlin: "fun main() { println(\"hi\") }",
objc: "@interface GNValidationProbe : NSObject\n@end",
swift: "func greet() { print(\"hi\") }", swift: "func greet() { print(\"hi\") }",
zig: "pub fn main() void {}",
}; };
const src = snippets[process.env.GRAMMAR];
if (!src) throw new Error("no validate snippet for grammar: " + process.env.GRAMMAR);
const lang = require("node-gyp-build")(process.cwd()); const lang = require("node-gyp-build")(process.cwd());
const Parser = require("tree-sitter"); const Parser = require("tree-sitter");
const p = new Parser(); p.setLanguage(lang); const p = new Parser(); p.setLanguage(lang);
const tree = p.parse(src); const tree = p.parse(snippets[process.env.GRAMMAR]);
if (!tree || !tree.rootNode || tree.rootNode.hasError) { if (!tree || !tree.rootNode || tree.rootNode.hasError) {
throw new Error("parse failed/error: " + (tree && tree.rootNode && tree.rootNode.type)); throw new Error("parse failed/error: " + (tree && tree.rootNode && tree.rootNode.type));
} }
@ -565,7 +517,7 @@ jobs:
app-id: ${{ secrets.RELEASE_APP_ID }} app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
# On a (non-fork) PR, check out the PR's HEAD branch — not the merge ref — # On a (non-fork) PR, check out the PR's HEAD branch — not the merge ref —

View file

@ -36,7 +36,7 @@ jobs:
# persist-credentials: false — this job only reads (tests and syntax # persist-credentials: false — this job only reads (tests and syntax
# checks) and never pushes. The setting keeps GITHUB_TOKEN out of # checks) and never pushes. The setting keeps GITHUB_TOKEN out of
# .git/config, which zizmor flags as the "artipacked" issue. # .git/config, which zizmor flags as the "artipacked" issue.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@ -57,7 +57,7 @@ jobs:
# persist-credentials: false — this is a read-only build smoke that # persist-credentials: false — this is a read-only build smoke that
# never pushes. The setting keeps GITHUB_TOKEN out of .git/config, # never pushes. The setting keeps GITHUB_TOKEN out of .git/config,
# which zizmor flags as the "artipacked" issue. # which zizmor flags as the "artipacked" issue.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0

View file

@ -14,7 +14,7 @@ jobs:
outputs: outputs:
web_changed: ${{ steps.filter.outputs.web }} web_changed: ${{ steps.filter.outputs.web }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v3 - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v3
@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false

View file

@ -13,7 +13,7 @@ jobs:
# canceled prettier at the 5-minute job cap; lint needed 7m41s the same run. # canceled prettier at the 5-minute job cap; lint needed 7m41s the same run.
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@ -43,7 +43,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
# tsc --noEmit reads source + gitnexus-shared/dist. Skip prepare/postinstall # tsc --noEmit reads source + gitnexus-shared/dist. Skip prepare/postinstall
@ -53,9 +53,6 @@ jobs:
lifecycle-scripts: 'false' lifecycle-scripts: 'false'
- run: npx tsc --noEmit - run: npx tsc --noEmit
working-directory: gitnexus working-directory: gitnexus
- name: Typecheck tests
run: npm run typecheck:tests
working-directory: gitnexus
typecheck-web: typecheck-web:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -64,7 +61,7 @@ jobs:
# run is cold again. # run is cold again.
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: ./.github/actions/setup-gitnexus-web - uses: ./.github/actions/setup-gitnexus-web
@ -87,7 +84,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Validate workflow concurrency convention - name: Validate workflow concurrency convention

View file

@ -125,7 +125,7 @@ jobs:
- name: Checkout (for vitest config) - name: Checkout (for vitest config)
if: steps.meta.outputs.skip != 'true' if: steps.meta.outputs.skip != 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
sparse-checkout: gitnexus/vitest.config.ts sparse-checkout: gitnexus/vitest.config.ts
sparse-checkout-cone-mode: false sparse-checkout-cone-mode: false
@ -287,7 +287,6 @@ jobs:
# ── Locate test results ── # ── Locate test results ──
RESULTS_FILE=$(find_first "$DIR/test-reports" "test-results.json") RESULTS_FILE=$(find_first "$DIR/test-reports" "test-results.json")
WEB_RESULTS_FILE=$(find_first "$DIR/test-reports" "web-test-results.json") WEB_RESULTS_FILE=$(find_first "$DIR/test-reports" "web-test-results.json")
PYTEST_RESULTS_FILE=$(find_first "$DIR/test-reports" "pytest-results.json")
sum_results() { sum_results() {
local file=$1 local file=$1
@ -304,21 +303,12 @@ jobs:
# framework, not as a top-line metric). # framework, not as a top-line metric).
read -r CLI_T CLI_P CLI_F CLI_S _ CLI_D <<< "$(sum_results "$RESULTS_FILE")" read -r CLI_T CLI_P CLI_F CLI_S _ CLI_D <<< "$(sum_results "$RESULTS_FILE")"
read -r WEB_T WEB_P WEB_F WEB_S _ WEB_D <<< "$(sum_results "$WEB_RESULTS_FILE")" read -r WEB_T WEB_P WEB_F WEB_S _ WEB_D <<< "$(sum_results "$WEB_RESULTS_FILE")"
read -r PY_T PY_P PY_F PY_S _ PY_D <<< "$(sum_results "$PYTEST_RESULTS_FILE")"
TOTAL=$((CLI_T + WEB_T + PY_T)) TOTAL=$((CLI_T + WEB_T))
PASSED=$((CLI_P + WEB_P + PY_P)) PASSED=$((CLI_P + WEB_P))
FAILED=$((CLI_F + WEB_F + PY_F)) FAILED=$((CLI_F + WEB_F))
SKIPPED=$((CLI_S + WEB_S + PY_S)) SKIPPED=$((CLI_S + WEB_S))
DURATION=$((CLI_D > WEB_D ? CLI_D : WEB_D)) DURATION=$((CLI_D > WEB_D ? CLI_D : WEB_D))
DURATION=$((DURATION > PY_D ? DURATION : PY_D))
EXECUTION_ERRORS=0
for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE" "$PYTEST_RESULTS_FILE"; do
if [ -n "$rf" ] && [ -f "$rf" ]; then
errors=$(jq -r '(.executionFailures // []) | length' "$rf")
EXECUTION_ERRORS=$((EXECUTION_ERRORS + errors))
fi
done
# ── Status helpers ── # ── Status helpers ──
status_icon() { status_icon() {
@ -395,22 +385,22 @@ jobs:
if [ "$TOTAL" -gt 0 ] 2>/dev/null; then if [ "$TOTAL" -gt 0 ] 2>/dev/null; then
echo "### Test Results" echo "### Test Results"
echo "" echo ""
echo "| Tests | Passed | Failed | Unverified | Duration |" echo "| Tests | Passed | Failed | Skipped | Duration |"
echo "|-------|--------|--------|---------|----------|" echo "|-------|--------|--------|---------|----------|"
echo "| ${TOTAL} | ${PASSED} | ${FAILED} | ${SKIPPED} | ${DURATION}s |" echo "| ${TOTAL} | ${PASSED} | ${FAILED} | ${SKIPPED} | ${DURATION}s |"
echo "" echo ""
if [[ "$FAILED" == "0" && "$SKIPPED" == "0" && "$EXECUTION_ERRORS" == "0" && "$TESTS" == "success" ]]; then if [ "$FAILED" = "0" ]; then
echo "✅ All **${PASSED}** tests passed" echo "✅ All **${PASSED}** tests passed"
else else
echo "❌ Test execution is incomplete or failed: **${FAILED}** failed, **${SKIPPED}** unverified, **${EXECUTION_ERRORS}** runner/suite errors." echo "❌ **${FAILED}** failed / **${PASSED}** passed"
fi fi
if [ "$SKIPPED" != "0" ]; then if [ "$SKIPPED" != "0" ]; then
echo "" echo ""
echo "<details>" echo "<details>"
echo "<summary>${SKIPPED} test(s) have no recorded pass — expand for details</summary>" echo "<summary>${SKIPPED} test(s) skipped — expand for details</summary>"
echo "" echo ""
for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE" "$PYTEST_RESULTS_FILE"; do for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE"; do
if [ -n "$rf" ] && [ -f "$rf" ]; then if [ -n "$rf" ] && [ -f "$rf" ]; then
jq -r ' jq -r '
.testResults[] .testResults[]

View file

@ -24,20 +24,19 @@ jobs:
shard: ${{ fromJSON(needs.shard-plan.outputs.cov_shards) }} shard: ${{ fromJSON(needs.shard-plan.outputs.cov_shards) }}
# Fail loudly (don't silently skip) if the FTS extension is unavailable, so # Fail loudly (don't silently skip) if the FTS extension is unavailable, so
# FTS-dependent lbug integration suites are guaranteed to run in CI. # FTS-dependent lbug integration suites are guaranteed to run in CI.
# Same contract for Zig's vendored grammar: this runner is linux-x64, # Same contract for Zig's optionalDependency grammar: this runner is
# which vendor/tree-sitter-zig ships a prebuild for, so an absent grammar # linux-x64, which @tree-sitter-grammars/tree-sitter-zig publishes a
# here is a packaging regression and not an unsupported platform. Without # prebuild for, so an absent grammar here is a packaging regression and not
# it every Zig suite skips and the job is green having never executed the # an unsupported platform. Without it every Zig suite skips and the job is
# native Zig parser once. # green having never executed the native Zig parser once.
env: env:
GITNEXUS_REQUIRE_FTS: '1' GITNEXUS_REQUIRE_FTS: '1'
GITNEXUS_REQUIRE_ZIG: '1' GITNEXUS_REQUIRE_ZIG: '1'
GITNEXUS_REQUIRE_VECTOR: '1'
steps: steps:
# persist-credentials: false — runs tests + uploads a blob artifact; the # persist-credentials: false — runs tests + uploads a blob artifact; the
# default-persisted token must not be capturable through it (zizmor # default-persisted token must not be capturable through it (zizmor
# credential-persistence / artipacked audit). The job never pushes. # credential-persistence / artipacked audit). The job never pushes.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: ./.github/actions/setup-gitnexus - uses: ./.github/actions/setup-gitnexus
@ -80,8 +79,8 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: coverage-blob-${{ matrix.shard }} name: coverage-blob-${{ matrix.shard }}
path: gitnexus/.vitest/blob/ path: gitnexus/.vitest-reports/
# .vitest is a dotdir; upload-artifact excludes hidden files by # .vitest-reports is a dotdir; upload-artifact excludes hidden files by
# default, which would upload an empty artifact and break the merge. # default, which would upload an empty artifact and break the merge.
include-hidden-files: true include-hidden-files: true
retention-days: 5 retention-days: 5
@ -100,7 +99,7 @@ jobs:
env: env:
GITNEXUS_REQUIRE_FTS: '1' GITNEXUS_REQUIRE_FTS: '1'
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: ./.github/actions/setup-gitnexus - uses: ./.github/actions/setup-gitnexus
@ -110,19 +109,19 @@ jobs:
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with: with:
pattern: coverage-blob-* pattern: coverage-blob-*
path: gitnexus/.vitest/blob path: gitnexus/.vitest-reports
merge-multiple: true merge-multiple: true
- name: Merge coverage + enforce thresholds - name: Merge coverage + enforce thresholds
run: >- run: >-
npx vitest --mergeReports=.vitest/blob npx vitest --mergeReports
--reporter=default --reporter=default
--reporter=./scripts/execution-reporter.ts --reporter=json
--outputFile=test-results.json --outputFile=test-results.json
--coverage --coverage
--coverage.reporter=json-summary --coverage.reporter=json-summary
--coverage.reporter=json --coverage.reporter=json
--coverage.reporter=text --coverage.reporter=text
--coverage.thresholds.autoUpdate=false --coverage.thresholdAutoUpdate=false
working-directory: gitnexus working-directory: gitnexus
# gitnexus-shared already built by setup-gitnexus above # gitnexus-shared already built by setup-gitnexus above
- name: Install gitnexus-web dependencies - name: Install gitnexus-web dependencies
@ -132,8 +131,7 @@ jobs:
run: >- run: >-
npx vitest run npx vitest run
--reporter=default --reporter=default
--reporter=../gitnexus/scripts/execution-reporter.ts --reporter=json
--includeTaskLocation
--outputFile=web-test-results.json --outputFile=web-test-results.json
working-directory: gitnexus-web working-directory: gitnexus-web
- name: Run docker-server integration tests - name: Run docker-server integration tests
@ -142,7 +140,7 @@ jobs:
if: always() if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: coverage-reports name: test-reports
path: | path: |
gitnexus/coverage/coverage-summary.json gitnexus/coverage/coverage-summary.json
gitnexus/coverage/coverage-final.json gitnexus/coverage/coverage-final.json
@ -164,7 +162,7 @@ jobs:
steps: steps:
- id: gen - id: gen
run: | run: |
TOTAL=4 # cross-platform (windows/macOS) shards per OS TOTAL=3 # cross-platform (windows/macOS) shards per OS
COV_TOTAL=3 # ubuntu coverage shards (merged before thresholds) COV_TOTAL=3 # ubuntu coverage shards (merged before thresholds)
if [ "$TOTAL" -lt 1 ] || [ "$COV_TOTAL" -lt 1 ]; then if [ "$TOTAL" -lt 1 ] || [ "$COV_TOTAL" -lt 1 ]; then
echo "shard totals must be >= 1" >&2; exit 1 echo "shard totals must be >= 1" >&2; exit 1
@ -225,7 +223,7 @@ jobs:
steps: steps:
# persist-credentials: false — runs tests only, never pushes (zizmor # persist-credentials: false — runs tests only, never pushes (zizmor
# credential-persistence / artipacked audit). # credential-persistence / artipacked audit).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: ./.github/actions/setup-gitnexus - uses: ./.github/actions/setup-gitnexus
@ -256,48 +254,8 @@ jobs:
shell: bash shell: bash
env: env:
SHARD: ${{ matrix.shard }}/${{ needs.shard-plan.outputs.total }} SHARD: ${{ matrix.shard }}/${{ needs.shard-plan.outputs.total }}
GITNEXUS_TEST_REPORT: ${{ matrix.os }}-${{ matrix.shard }}.json
run: npx tsx scripts/run-cross-platform.ts --shard="$SHARD" run: npx tsx scripts/run-cross-platform.ts --shard="$SHARD"
working-directory: gitnexus working-directory: gitnexus
- name: Upload platform execution report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-${{ matrix.os }}-${{ matrix.shard }}
path: gitnexus/${{ matrix.os }}-${{ matrix.shard }}.json
if-no-files-found: error
retention-days: 5
# Branch protection still requires these six names from the three-shard
# matrix. Keep them as aggregate gates as the real matrix grows: every native
# shard and the complete execution audit must pass before any gate succeeds.
# These jobs only verify dependency results; tests run in cross-platform above.
protected-platform-checks:
name: ${{ matrix.os }} (platform-sensitive) ${{ matrix.check }}/3
needs: [cross-platform, test-completeness]
if: always()
permissions: {}
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest]
check: [1, 2, 3]
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Require all native shards and the execution audit
shell: bash
env:
NATIVE_RESULT: ${{ needs.cross-platform.result }}
EXECUTION_RESULT: ${{ needs.test-completeness.result }}
run: |
echo "Compatibility gate for the former three-shard check name."
echo "All native shards: $NATIVE_RESULT"
echo "Complete execution audit: $EXECUTION_RESULT"
if [[ "$NATIVE_RESULT" != "success" || "$EXECUTION_RESULT" != "success" ]]; then
echo "::error::Every native shard and the execution audit must succeed."
exit 1
fi
# Tree-sitter ABI gate (#1922). Two halves, both blocking: # Tree-sitter ABI gate (#1922). Two halves, both blocking:
# 1. Static, offline: assert every grammar's compiled ABI loads on the # 1. Static, offline: assert every grammar's compiled ABI loads on the
@ -315,7 +273,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: ./.github/actions/setup-gitnexus - uses: ./.github/actions/setup-gitnexus
@ -326,12 +284,10 @@ jobs:
shell: bash shell: bash
run: python3 .github/scripts/check-tree-sitter-upgrade-readiness.py --assert-current run: python3 .github/scripts/check-tree-sitter-upgrade-readiness.py --assert-current
# GITNEXUS_REQUIRE_ZIG=1: every OS in this matrix has a committed # GITNEXUS_REQUIRE_ZIG=1: every OS in this matrix has a published
# vendored tree-sitter-zig prebuild (linux-arm64 is rebuilt by the # tree-sitter-zig prebuild, so the smoke's "optional grammar may be
# prebuild workflow; ubuntu/windows/macos latest are x64/arm64 with # absent" exemption is revoked here and an ABI-broken Zig binding fails
# shipped binaries), so the smoke's "optional grammar may be absent" # the job instead of being accepted as a clean absence.
# exemption is revoked here and an ABI-broken Zig binding fails the
# job instead of being accepted as a clean absence.
- name: Run parser-loader ABI load-smoke (dynamic) - name: Run parser-loader ABI load-smoke (dynamic)
run: npx vitest run test/unit/parser-loader-abi.test.ts run: npx vitest run test/unit/parser-loader-abi.test.ts
env: env:
@ -359,7 +315,7 @@ jobs:
# from a tarball and never pushes back; the token in .git/config would # from a tarball and never pushes back; the token in .git/config would
# be at risk of leaking through any future artifact-upload step # be at risk of leaking through any future artifact-upload step
# (zizmor artipacked audit). Disable upfront. # (zizmor artipacked audit). Disable upfront.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
# Skip prepare/postinstall/build here. `npm pack` runs prepack, which # Skip prepare/postinstall/build here. `npm pack` runs prepack, which
@ -472,7 +428,7 @@ jobs:
steps: steps:
# persist-credentials: false — builds and import-links only, never pushes # persist-credentials: false — builds and import-links only, never pushes
# (zizmor credential-persistence / artipacked audit). # (zizmor credential-persistence / artipacked audit).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@ -524,8 +480,11 @@ jobs:
# heap, so parallel forks both skew the timings and OOM the worker pool — they # heap, so parallel forks both skew the timings and OOM the worker pool — they
# must run one file at a time. # must run one file at a time.
# #
# Every opt-in suite is discovered by run-benchmarks.ts. A failing worker # go-pipeline-benchmark.test.ts is deliberately NOT included: its
# benchmark is a failure to investigate, never a reason to omit that suite. # worker-pool (#1848) suite spins a real worker pool that exits unexpectedly
# under vitest's fork pool (reproduced in validation), which would make this
# gate flaky. Go is already guarded by its non-gated O(n^2) tripwire (runs in
# the main coverage job) plus its golden capture-parity test.
benchmarks: benchmarks:
name: benchmarks (GITNEXUS_BENCH) name: benchmarks (GITNEXUS_BENCH)
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -535,7 +494,7 @@ jobs:
# and never pushes; the default-persisted token in .git/config would be at # and never pushes; the default-persisted token in .git/config would be at
# risk of leaking through an artifact upload (zizmor credential-persistence # risk of leaking through an artifact upload (zizmor credential-persistence
# / artipacked audit). Mirrors the packaged-install-smoke job below. # / artipacked audit). Mirrors the packaged-install-smoke job below.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: ./.github/actions/setup-gitnexus - uses: ./.github/actions/setup-gitnexus
@ -562,15 +521,6 @@ jobs:
run: node --import tsx bench/kotlin-star-route-constants/measure.mjs --check run: node --import tsx bench/kotlin-star-route-constants/measure.mjs --check
working-directory: gitnexus working-directory: gitnexus
- name: tRPC identifier-mount route extractor guards (#3339)
if: ${{ !cancelled() }}
# Build-free: inline-router control vs identifier-mounted subrouters
# and a transitive mount chain; fingerprints route paths and guards
# scaling + widening overhead (compose must stay linear in procedure
# count, and must not degrade the inline scan).
run: node --import tsx bench/trpc-route-extractor/measure.mjs --check
working-directory: gitnexus
- name: Cross-language scope-capture fingerprint + scaling guards - name: Cross-language scope-capture fingerprint + scaling guards
# Runs even after an earlier guard fails (#2895). Every step here was # Runs even after an earlier guard fails (#2895). Every step here was
# fail-fast, so the FIRST failing --check aborted the job and every guard # fail-fast, so the FIRST failing --check aborted the job and every guard
@ -635,59 +585,6 @@ jobs:
run: node --import tsx bench/finalize-reexport/measure.mjs --check run: node --import tsx bench/finalize-reexport/measure.mjs --check
working-directory: gitnexus working-directory: gitnexus
- name: Parse dispatch-round cadence guards (#3194, #3196)
if: ${{ !cancelled() }}
# Build-free: asserts parse-cache pack membership is unchanged
# (fingerprint — every cache key derives from it), that a fixed corpus
# still batches into a fixed number of dispatch rounds, and that the
# round budget counts UTF-8 bytes rather than UTF-16 code units. Round
# boundaries are deliberately invisible to graph output, so no test can
# see these regress. Rationale and history: see the header of
# bench/parse-dispatch-rounds/measure.mjs.
run: node --import tsx bench/parse-dispatch-rounds/measure.mjs --check
working-directory: gitnexus
- name: Python workspace import-scan guards (#3254)
if: ${{ !cancelled() }}
# Build-free: same baseline approach as parse-dispatch-rounds —
# exact link/lookalike floors plus a fingerprint, then ratio timing
# only (scan scaling and from-token prefilter advantage). See
# bench/python-workspace-import-scan/measure.mjs.
run: node --import tsx bench/python-workspace-import-scan/measure.mjs --check
working-directory: gitnexus
- name: Rust Cargo target membership guards (#3253)
if: ${{ !cancelled() }}
# Build-free: same baseline approach as parse-dispatch-rounds —
# exact membership floors plus a fingerprint, then ratio timing
# only (loadRustCargoTargets 4n/n). Pins typical-Rust completeness
# (derive / println!), include!-abort, and src/target vs Cargo
# artifact layouts. See bench/rust-cargo-targets/measure.mjs.
run: node --import tsx bench/rust-cargo-targets/measure.mjs --check
working-directory: gitnexus
- name: Swift Package.swift import-resolve guards (#2964, #2931)
if: ${{ !cancelled() }}
# Build-free: same baseline approach as parse-dispatch-rounds —
# exact declared/SDK/undeclared floors plus a fingerprint, then
# ratio timing only (resolveSwiftImportTarget, swiftPackageStrategy,
# parseSwiftPackageManifest 4n/n). Pins declaration-only resolve,
# https:// factory survival, and #2931 segment-boundary membership.
# See bench/swift-package-imports/measure.mjs.
run: node --import tsx bench/swift-package-imports/measure.mjs --check
working-directory: gitnexus
- name: MCP tools/list countRepos vs listRepos guards (#3259, #3184)
if: ${{ !cancelled() }}
# Build-free: exact registry cardinality + tool-roster + schema-flag
# floors, then ratio timing only (countRepos/listRepos and
# listTools/listRepos). No millisecond ceiling — this repo has
# already been bitten by a fixed ms budget. Isolated GITNEXUS_HOME;
# fixture is N real git repos so listRepos pays rev-list. See
# bench/mcp-tools-list/measure.mjs.
run: node --import tsx bench/mcp-tools-list/measure.mjs --check
working-directory: gitnexus
- name: C++ qualified-namespace resolution guards (#2788) - name: C++ qualified-namespace resolution guards (#2788)
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
# Build-free: asserts resolveCppQualifiedNamespaceMember resolves an # Build-free: asserts resolveCppQualifiedNamespaceMember resolves an
@ -807,13 +704,6 @@ jobs:
run: node --import tsx bench/kotlin-import-target/measure.mjs --check run: node --import tsx bench/kotlin-import-target/measure.mjs --check
working-directory: gitnexus working-directory: gitnexus
- name: Ruby gem-boundary correctness + scaling guards (#3096)
if: ${{ !cancelled() }}
# Includes real manifest loading; checks scoped resolution and scaling
# as sibling projects or declared gem counts grow independently.
run: node --import tsx bench/ruby-gem-resolution/measure.mjs --check
working-directory: gitnexus
- name: Receiver-resolution drop guards - name: Receiver-resolution drop guards
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
# NOT build-free: this one runs the real pipeline, so it needs dist/ # NOT build-free: this one runs the real pipeline, so it needs dist/
@ -853,35 +743,6 @@ jobs:
run: node --import tsx bench/scope-emission/measure.mjs --check run: node --import tsx bench/scope-emission/measure.mjs --check
working-directory: gitnexus working-directory: gitnexus
- name: Zig cross-file static-gating guards (#3162)
if: ${{ !cancelled() }}
# Build-free: fingerprints cross-file dead-call classification and
# guards the workspace enrichment pass across file-count scaling.
run: node --import tsx bench/zig-cross-file-resolution/measure.mjs --check
working-directory: gitnexus
- name: Objective-C workspace resolution guards (#3179)
if: ${{ !cancelled() }}
# Build-free: fingerprints spread (typed self/super/sibling) and
# protocol-candidate evidence, and gates linear file-count scaling
# of emitPostResolutionEdges. Import lookup is the shared
# import-target `objc` arm; this is the C#/Zig analog for the
# workspace message-send pass.
run: node --import tsx bench/objective-c-resolution/measure.mjs --check
working-directory: gitnexus
- name: Callable-value reference resolution guards (#3399)
if: ${{ !cancelled() }}
# Build-free: pins the resolved-target SET of `resolveValueRefTarget`
# (exact site/resolved/declined counts plus an order-independent
# fingerprint) and asserts its per-site cost stays independent of
# workspace size across a 4x file-count step. The pass resolves a
# qualified receiver through `scopes.qualifiedNames`, a workspace-wide
# index: keyed it is O(1) per site, scanned it is O(files) — a
# regression a fixture cannot see and a 257-file binding table can.
run: node --import tsx bench/value-ref-resolution/measure.mjs --check
working-directory: gitnexus
- name: CFG construction time / disk / memory guards (#2081 M1) - name: CFG construction time / disk / memory guards (#2081 M1)
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
# Build-free: asserts collectFunctionCfgs output is unchanged # Build-free: asserts collectFunctionCfgs output is unchanged
@ -915,18 +776,26 @@ jobs:
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial) - name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
# cpp-adl-benchmark.test.ts and csharp-razor-view-components-benchmark.test.ts
# are not `*-pipeline-benchmark.test.ts` files but belong here for the
# same reason: they are skipIf-gated on GITNEXUS_BENCH, so the scaling
# guards they hold never run in the main coverage job.
env: env:
GITNEXUS_WORKER_READY_TIMEOUT_MS: '60000' GITNEXUS_BENCH: '1'
run: npm run test:benchmarks run: >-
npx vitest run --no-file-parallelism
test/integration/cobol-pipeline-benchmark.test.ts
test/integration/csharp-pipeline-benchmark.test.ts
test/integration/csharp-razor-view-components-benchmark.test.ts
test/integration/cpp-adl-benchmark.test.ts
test/integration/data-route-table-benchmark.test.ts
test/integration/instance-ownership-pipeline-benchmark.test.ts
test/integration/spring-bean-resource-benchmark.test.ts
test/integration/spring-dynamic-lookup-benchmark.test.ts
test/integration/rust-pipeline-benchmark.test.ts
test/integration/php-pipeline-benchmark.test.ts
test/integration/ruby-pipeline-benchmark.test.ts
working-directory: gitnexus working-directory: gitnexus
- name: Upload benchmark execution report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-benchmarks
path: gitnexus/benchmarks.json
if-no-files-found: error
retention-days: 5
# Locked eval suite. setup-uv and uv itself are immutable so CI exercises # Locked eval suite. setup-uv and uv itself are immutable so CI exercises
# exactly the dependency graph developers run from eval/uv.lock. # exactly the dependency graph developers run from eval/uv.lock.
@ -936,97 +805,17 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
# persist-credentials: false — runs tests only, never pushes. # persist-credentials: false — runs tests only, never pushes.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with: with:
version: '0.11.23' version: '0.11.23'
python-version: '3.13' python-version: '3.13'
enable-cache: true enable-cache: true
cache-dependency-glob: eval/uv.lock cache-dependency-glob: eval/uv.lock
- run: uv run --locked --extra dev python -m pytest tests -q --junitxml=pytest-locked.xml - run: uv run --locked --extra dev python -m pytest tests -q
working-directory: eval working-directory: eval
- name: Upload pytest execution report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-pytest-locked
path: eval/pytest-locked.xml
if-no-files-found: error
retention-days: 5
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
- name: Exercise real workflow evidence preflight
if: ${{ !cancelled() }}
run: >-
npx vitest run test/unit/skill-evolution-workflow.test.ts
--reporter=default --reporter=./scripts/execution-reporter.ts --outputFile=preflight.json
working-directory: gitnexus
- name: Upload preflight execution report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-preflight
path: gitnexus/preflight.json
if-no-files-found: error
retention-days: 5
test-completeness:
name: every test executed
needs:
[
shard-plan,
coverage-merge,
cross-platform,
benchmarks,
eval-tests,
eval-containment-linux,
eval-containment-windows,
]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
- name: Download coverage and web reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: coverage-reports
- name: Download all execution receipts
if: ${{ !cancelled() }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: execution-*
path: execution-reports
merge-multiple: true
- name: Require a real pass for every collected test
env:
PLATFORM_SHARDS: ${{ needs.shard-plan.outputs.total }}
run: |
cp gitnexus/test-results.json execution-reports/coverage.json
cd gitnexus
node --import tsx scripts/test-completeness.ts ../execution-reports test-results.json "$PLATFORM_SHARDS"
- name: Require a real pass for every pytest case
if: ${{ !cancelled() }}
run: python3 eval/check_test_execution.py execution-reports eval/pytest-results.json
- name: Upload complete test reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-reports
path: |
gitnexus/coverage/coverage-summary.json
gitnexus/coverage/coverage-final.json
gitnexus/test-results.json
gitnexus-web/web-test-results.json
eval/pytest-results.json
if-no-files-found: error
retention-days: 5
# Native Linux ownership and Bubblewrap boundary. The environment flag makes # Native Linux ownership and Bubblewrap boundary. The environment flag makes
# the real namespace test mandatory; a missing/blocked bwrap is a failure. # the real namespace test mandatory; a missing/blocked bwrap is a failure.
@ -1036,14 +825,9 @@ jobs:
timeout-minutes: 20 timeout-minutes: 20
env: env:
GITNEXUS_REQUIRE_BWRAP_CANARY: '1' GITNEXUS_REQUIRE_BWRAP_CANARY: '1'
# This job installs bubblewrap, the pinned runtime and a built GitNexus,
# so the offline sweep runs here with nothing provisioning-stubbed: real
# containment, real mounts, real graph. A missing piece fails the job
# rather than silently falling back to the stubbed path.
GITNEXUS_REQUIRE_FULL_SWEEP: '1'
GITNEXUS_REQUIRE_CLAUDE_CANARY: '1' GITNEXUS_REQUIRE_CLAUDE_CANARY: '1'
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@ -1053,7 +837,7 @@ jobs:
cache-dependency-path: | cache-dependency-path: |
gitnexus/package-lock.json gitnexus/package-lock.json
gitnexus-shared/package-lock.json gitnexus-shared/package-lock.json
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with: with:
version: '0.11.23' version: '0.11.23'
python-version: '3.13' python-version: '3.13'
@ -1063,7 +847,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
sudo apt-get update sudo apt-get update
sudo apt-get install --yes --no-install-recommends bubblewrap ripgrep socat sudo apt-get install --yes --no-install-recommends bubblewrap socat
apparmor_userns=/proc/sys/kernel/apparmor_restrict_unprivileged_userns apparmor_userns=/proc/sys/kernel/apparmor_restrict_unprivileged_userns
if [[ -r "${apparmor_userns}" ]] && [[ "$(<"${apparmor_userns}")" == '1' ]]; then if [[ -r "${apparmor_userns}" ]] && [[ "$(<"${apparmor_userns}")" == '1' ]]; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
@ -1099,21 +883,8 @@ jobs:
tests/test_process_control.py tests/test_process_control.py
tests/test_proposer_sandbox.py tests/test_proposer_sandbox.py
tests/test_workflow_bench_sessions.py tests/test_workflow_bench_sessions.py
tests/test_ce_plugin_runtime.py tests/test_ce_plugin_runtime.py -q
tests/test_offline_sweep_integration.py
tests/test_mock_provider.py
tests/test_evolve.py::test_outer_runner_pid_namespace_kills_setsid_descendant
tests/test_oracle_assets.py::test_hidden_vitest_config_executes_sibling_oracle_against_candidate_checkout
-q --junitxml=pytest-ubuntu.xml
working-directory: eval working-directory: eval
- name: Upload pytest execution report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-pytest-ubuntu
path: eval/pytest-ubuntu.xml
if-no-files-found: error
retention-days: 5
# Native Windows Job Object canary. POSIX-only tests skip by platform, while # Native Windows Job Object canary. POSIX-only tests skip by platform, while
# the grandchild delayed-write test must execute and pass on this runner. # the grandchild delayed-write test must execute and pass on this runner.
@ -1122,10 +893,10 @@ jobs:
runs-on: windows-latest runs-on: windows-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with: with:
version: '0.11.23' version: '0.11.23'
python-version: '3.13' python-version: '3.13'
@ -1134,14 +905,5 @@ jobs:
- name: Prove Windows process-tree ownership - name: Prove Windows process-tree ownership
run: >- run: >-
uv run --locked --extra dev python -m pytest uv run --locked --extra dev python -m pytest
tests/test_process_control.py tests/test_model_gateway.py tests/test_process_control.py -q
-k "not locked_litellm" -q --junitxml=pytest-windows.xml
working-directory: eval working-directory: eval
- name: Upload pytest execution report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-pytest-windows
path: eval/pytest-windows.xml
if-no-files-found: error
retention-days: 5

View file

@ -129,7 +129,7 @@ jobs:
core.setOutput('code_review', isCodeReview ? 'true' : 'false'); core.setOutput('code_review', isCodeReview ? 'true' : 'false');
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }} repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }}
ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }} ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }}

View file

@ -42,13 +42,13 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Don't leave GITHUB_TOKEN in .git/config for downstream steps to read. # Don't leave GITHUB_TOKEN in .git/config for downstream steps to read.
persist-credentials: false persist-credentials: false
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: security-and-quality queries: security-and-quality
@ -77,16 +77,8 @@ jobs:
# GitHub PR CodeQL gate, so this file is excluded to avoid # GitHub PR CodeQL gate, so this file is excluded to avoid
# re-filing js/regex-injection on every push of the same line. # re-filing js/regex-injection on every push of the same line.
- 'gitnexus/src/server/grep-params.ts' - 'gitnexus/src/server/grep-params.ts'
# Tests construct tmpdir fixtures and pass them into production
# read-only probes (openSync(..., 'r')). CodeQL models that as
# js/insecure-temporary-file even though nothing is created.
- '**/test/**'
# CI vendor fetch: origin is the official Ladybug repo; dest is
# regex-pinned and containment-checked. Inline suppressions do
# not clear the PR CodeQL gate (same as grep-params.ts).
- '.github/scripts/fetch-lbug-fts-artifacts.mjs'
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with: with:
category: '/language:${{ matrix.language }}' category: '/language:${{ matrix.language }}'

View file

@ -135,53 +135,68 @@ jobs:
# workflow_run event. The allowlist above only proves the fields are # workflow_run event. The allowlist above only proves the fields are
# well-formed — not that they refer to the PR/SHA that actually triggered # well-formed — not that they refer to the PR/SHA that actually triggered
# us. A fork-controlled build could mutate metadata.json to reference # us. A fork-controlled build could mutate metadata.json to reference
# another PR/SHA and redirect our write-scoped push. # another PR/SHA and redirect our write-scoped push. Authority sources are
# # all server-controlled: workflow_run.head_sha, head_repository.full_name,
# This job's `if:` already restricts to forks, so pull_requests[] is empty # and pull_requests[].number (empty on forks -> commits/{sha}/pulls).
# by design and GET /repos/{base}/commits/{sha}/pulls is also empty (the
# fork commit is not in the base graph). Authority is workflow_run.head_sha
# + head_repository.full_name + head_branch, resolved via
# pulls?head={owner}:{branch}. The script comes from THIS default-branch
# checkout (the same trust anchor as this workflow file).
- name: Checkout identity verifier
if: steps.meta.outputs.deliver == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts/verify-workflow-run-pr-identity.cjs
sparse-checkout-cone-mode: false
path: trusted
- name: Verify metadata against workflow_run authority - name: Verify metadata against workflow_run authority
id: verify
if: steps.meta.outputs.deliver == 'true' if: steps.meta.outputs.deliver == 'true'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }} GH_REPO: ${{ github.repository }}
META_PATH: meta-in/metadata.json META_PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
SCHEMA_PATTERN: '^gitnexus\.ts-prebuild/v[0-9]+$' META_HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
META_HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
WF_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} WF_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
WF_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} WF_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }}
WF_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} WF_PR_NUMBERS: ${{ toJSON(github.event.workflow_run.pull_requests.*.number) }}
shell: bash shell: bash
run: node trusted/.github/scripts/verify-workflow-run-pr-identity.cjs run: |
set -euo pipefail
# 1) head_sha must match exactly — the commit GitHub ran the producer against.
if [ "${META_HEAD_SHA}" != "${WF_HEAD_SHA}" ]; then
echo "::error::Artifact head_sha (${META_HEAD_SHA}) != workflow_run.head_sha (${WF_HEAD_SHA}) — refusing."
exit 1
fi
# 2) head_repo must match exactly.
if [ "${META_HEAD_REPO}" != "${WF_HEAD_REPO}" ]; then
echo "::error::Artifact head_repo (${META_HEAD_REPO}) != workflow_run.head_repository (${WF_HEAD_REPO}) — refusing."
exit 1
fi
# 3) pr_number must reference an open PR with this head SHA. Forks have
# an empty pull_requests[] by design — fall back to commits/{sha}/pulls.
allowed_numbers=$(jq -c '.' <<< "${WF_PR_NUMBERS}")
if [ "${allowed_numbers}" = "[]" ]; then
echo "workflow_run.pull_requests empty (fork) — using commits/{sha}/pulls."
allowed_numbers=$(gh api "repos/${GH_REPO}/commits/${WF_HEAD_SHA}/pulls" \
--jq '[.[] | select(.state == "open") | .number]' 2>/dev/null || echo "[]")
if [ "${allowed_numbers}" = "[]" ]; then
echo "::error::No open PR for head ${WF_HEAD_SHA} — refusing."
exit 1
fi
fi
if ! jq -e --argjson n "${META_PR_NUMBER}" 'index($n) != null' <<< "${allowed_numbers}" >/dev/null; then
echo "::error::Artifact pr_number (${META_PR_NUMBER}) not in authoritative list (${allowed_numbers}) — refusing."
exit 1
fi
echo "Verified identity: PR=${META_PR_NUMBER} head_sha=${META_HEAD_SHA} head_repo=${META_HEAD_REPO}."
# Pinned to v6.0.3 (same SHA used by build-tree-sitter-prebuilds.yml). # Pinned to v6.0.3 (same SHA used by build-tree-sitter-prebuilds.yml).
# persist-credentials: false — push auth is provided inline at push time, # persist-credentials: false — push auth is provided inline at push time,
# never written to .git/config on disk. # never written to .git/config on disk.
- name: Checkout fork PR head - name: Checkout fork PR head
if: steps.meta.outputs.deliver == 'true' && steps.verify.outcome == 'success' if: steps.meta.outputs.deliver == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
repository: ${{ steps.verify.outputs.head_repo }} repository: ${{ steps.meta.outputs.head_repo }}
ref: ${{ steps.verify.outputs.head_sha }} ref: ${{ steps.meta.outputs.head_sha }}
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
path: pr-checkout path: pr-checkout
- name: Place prebuilds into the fork checkout - name: Place prebuilds into the fork checkout
if: steps.meta.outputs.deliver == 'true' && steps.verify.outcome == 'success' if: steps.meta.outputs.deliver == 'true'
env: env:
DL: prebuilds-in DL: prebuilds-in
CHECKOUT: pr-checkout CHECKOUT: pr-checkout
@ -223,12 +238,12 @@ jobs:
- name: Commit and push to the fork branch - name: Commit and push to the fork branch
id: push id: push
if: steps.meta.outputs.deliver == 'true' && steps.verify.outcome == 'success' if: steps.meta.outputs.deliver == 'true'
working-directory: pr-checkout working-directory: pr-checkout
env: env:
HEAD_REF: ${{ steps.verify.outputs.head_ref }} HEAD_REF: ${{ steps.meta.outputs.head_ref }}
HEAD_REPO: ${{ steps.verify.outputs.head_repo }} HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
HEAD_SHA: ${{ steps.verify.outputs.head_sha }} HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
# Push auth only — supplied via env, never interpolated into the command. # Push auth only — supplied via env, never interpolated into the command.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash shell: bash
@ -288,11 +303,11 @@ jobs:
fi fi
- name: Comment delivery outcome - name: Comment delivery outcome
if: always() && steps.meta.outputs.deliver == 'true' && steps.verify.outcome == 'success' && steps.push.outcome != 'skipped' if: always() && steps.meta.outputs.deliver == 'true' && steps.push.outcome != 'skipped'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }} GH_REPO: ${{ github.repository }}
PR: ${{ steps.verify.outputs.pr_number }} PR: ${{ steps.meta.outputs.pr_number }}
RESULT: ${{ steps.push.outputs.result }} RESULT: ${{ steps.push.outputs.result }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
shell: bash shell: bash

View file

@ -28,7 +28,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false

View file

@ -74,10 +74,8 @@ jobs:
- name: gitnexus-web - name: gitnexus-web
dockerfile: Dockerfile.web dockerfile: Dockerfile.web
slug: gitnexus-web slug: gitnexus-web
# CLI / `gitnexus serve` backend. Tree-sitter natives live in this # CLI / `gitnexus serve` backend. Heavy native deps (tree-sitter,
# image. onnxruntime-node is opt-in (`gitnexus embeddings install` or # onnxruntime-node) live only in this image.
# a bind-mounted prefix / GITNEXUS_EMBEDDING_URL); npm is stripped
# at runtime so the image cannot auto-heal the embedding stack.
- name: gitnexus - name: gitnexus
dockerfile: Dockerfile.cli dockerfile: Dockerfile.cli
slug: gitnexus slug: gitnexus
@ -103,7 +101,7 @@ jobs:
# When triggered by workflow_call the caller passes the RC tag as an input; # When triggered by workflow_call the caller passes the RC tag as an input;
# we check out that tag so the Dockerfile and package.json match the built image. # we check out that tag so the Dockerfile and package.json match the built image.
# For tag-push events github.ref is already the tag ref — no override needed. # For tag-push events github.ref is already the tag ref — no override needed.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: ${{ inputs.tag || github.ref }} ref: ${{ inputs.tag || github.ref }}
@ -140,10 +138,10 @@ jobs:
# Required for multi-platform (linux/arm64) emulation. # Required for multi-platform (linux/arm64) emulation.
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

View file

@ -29,7 +29,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Full history needed for the on-push full-history scan; on PRs the # Full history needed for the on-push full-history scan; on PRs the
# action diffs against the base ref so the cost is bounded by the PR. # action diffs against the base ref so the cost is bounded by the PR.

View file

@ -303,7 +303,7 @@ jobs:
- name: Checkout trusted workflow control plane - name: Checkout trusted workflow control plane
id: checkout-control id: checkout-control
if: steps.context.outputs.ready == 'true' if: steps.context.outputs.ready == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
repository: ${{ github.repository }} repository: ${{ github.repository }}
ref: ${{ steps.context.outputs.control_sha }} ref: ${{ steps.context.outputs.control_sha }}
@ -315,7 +315,7 @@ jobs:
- name: Checkout exact PR head as passive data - name: Checkout exact PR head as passive data
id: checkout-head id: checkout-head
if: steps.context.outputs.ready == 'true' if: steps.context.outputs.ready == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
repository: ${{ steps.context.outputs.head_repo }} repository: ${{ steps.context.outputs.head_repo }}
ref: ${{ steps.context.outputs.head_sha }} ref: ${{ steps.context.outputs.head_sha }}
@ -1294,7 +1294,7 @@ jobs:
steps.claude-recheck.outcome == 'success' steps.claude-recheck.outcome == 'success'
# Use the low-level base action: the high-level GitHub action can restore # Use the low-level base action: the high-level GitHub action can restore
# project configuration from a moving base branch before invoking Claude. # project configuration from a moving base branch before invoking Claude.
uses: anthropics/claude-code-action/base-action@12dd8d74c712f5f3669365b2369b558c495b1104 # v1 uses: anthropics/claude-code-action/base-action@3553f84341b92da26052e28acf1aa898f9511f32 # v1
env: env:
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1' CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1'
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '0' CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '0'
@ -1447,7 +1447,7 @@ jobs:
if: >- if: >-
steps.precheck.outputs.repair_reason != '' && steps.precheck.outputs.repair_reason != '' &&
steps.repair-recheck.outcome == 'success' steps.repair-recheck.outcome == 'success'
uses: anthropics/claude-code-action/base-action@12dd8d74c712f5f3669365b2369b558c495b1104 # v1 uses: anthropics/claude-code-action/base-action@3553f84341b92da26052e28acf1aa898f9511f32 # v1
env: env:
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1' CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1'
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '0' CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '0'

View file

@ -4,22 +4,17 @@
# overlay. The gate is evidence FOR a PR, never a bypass of one — nothing # overlay. The gate is evidence FOR a PR, never a bypass of one — nothing
# merges without review. # merges without review.
# #
# Activation and operations checklist. # Activation checklist (the scheduled lane is OFF by default).
# [x] Configure at least one model secret on the `gitnexus-evolution` # [ ] Configure the repository secret GITNEXUS_BENCH_AUTH_TOKEN (an Anthropic
# Environment: GITNEXUS_BENCH_ANTHROPIC_API_KEY (Anthropic API key — not # API key — benchmark sessions bill real usage; the Claude Code OAuth
# the Claude Code OAuth token; legacy GITNEXUS_BENCH_AUTH_TOKEN is still # subscription token does not work here).
# accepted) and/or GITNEXUS_BENCH_OPENAI_API_KEY. Sessions bill real usage. # [ ] Configure the RELEASE_APP_ID and RELEASE_APP_PRIVATE_KEY secrets (the
# OpenAI keys are not native to Claude Code; the loop starts a loopback
# LiteLLM proxy and keeps the OpenAI key off the sandboxed agent. With only
# the OpenAI secret, or with provider=openai, dispatch-time Claude model
# defaults are gpt-5.6-sol with xhigh reasoning effort.
# [x] Configure the RELEASE_APP_ID and RELEASE_APP_PRIVATE_KEY secrets (the
# App that opens the promotion PR). The Mint-App-Token step hard-fails # App that opens the promotion PR). The Mint-App-Token step hard-fails
# without them once a promotion is detected. Verify the App installation # without them once a promotion is detected. Verify the App installation
# is scoped to this repo with only Contents: RW + Pull requests: RW. # is scoped to this repo with only Contents: RW + Pull requests: RW.
# [x] Create the protected Environment `gitnexus-evolution` with a # [x] Create the protected Environment `gitnexus-evolution` with a
# deployment-branch rule restricting it to `main`, and ideally scope the # deployment-branch rule restricting it to `main`, and ideally scope the
# four secrets above to that Environment. workflow_dispatch runs this # three secrets above to that Environment. workflow_dispatch runs this
# workflow (and eval/workflow_bench/evolve.py) from the *dispatched ref*, # workflow (and eval/workflow_bench/evolve.py) from the *dispatched ref*,
# so this server-side rule — not a code-side guard the branch could edit # so this server-side rule — not a code-side guard the branch could edit
# away — is what stops a non-main branch from running with the secrets. # away — is what stops a non-main branch from running with the secrets.
@ -45,35 +40,13 @@
# most weekly. Revisit if run frequency increases or the threat model # most weekly. Revisit if run frequency increases or the threat model
# changes; stopping already bounds the exposure window to the job's own # changes; stopping already bounds the exposure window to the job's own
# runtime on 1 day out of 7. # runtime on 1 day out of 7.
# [ ] Install and verify the runner survival policy below before enabling # [ ] Run workflow_dispatch once and confirm: containment preflight passes,
# scheduled runs. A run
# spans ~15h and apt-daily-upgrade.timer fires daily (~06:34), so every
# scheduled run crosses it. On 2026-08-02 unattended-upgrades upgraded
# openssl at 07:54:02 and needrestart restarted the Actions runner five
# seconds later: the job went to Canceled, and a cancelled job skips even
# `if: always()`, so the evidence artifact died with it. Keep installing
# updates, but never let them restart services here:
# /etc/needrestart/conf.d/90-gitnexus-evolution.conf
# $nrconf{restart} = 'l';
# A drop-in, so a needrestart package upgrade cannot clobber it. Nothing
# is left unpatched in practice — the box is stopped between runs, so the
# new binaries take effect at the next boot.
# [x] Run workflow_dispatch once and confirm: containment preflight passes,
# the benchmark completes inside the job timeout, the results artifact # the benchmark completes inside the job timeout, the results artifact
# uploads, and a promotion (if any) opens a well-formed PR. Run # uploads, and a promotion (if any) opens a well-formed PR.
# 29907431284 (2026-07-22) went green end to end in 14h45m and reached a # [ ] Set the repository variable GITNEXUS_EVOLUTION_ENABLED=true.
# gate decision (`insufficient_evidence`, no promotion). # Roll back by setting that variable to false. Note: workflow_dispatch always
# [ ] Confirm a workers=3 dispatch has zero excluded runs (review sessions in # runs the full benchmark loop regardless of GITNEXUS_EVOLUTION_ENABLED and
# 33962002890 averaged ~19m serial, well under the 90m session ceiling). # bills real API usage on GITNEXUS_BENCH_AUTH_TOKEN.
# Then set GITNEXUS_EVOLUTION_WORKERS=3 and
# GITNEXUS_EVOLUTION_ENABLED=true for scheduled runs. Scheduled runs
# require both values, so leaving the var unset is an immediate rollback.
# Dispatch defaults to 3; pass workers=1 only to debug a contended host.
# Weekly generations reuse matching incumbent/CE cells from the previous
# artifact so the paid matrix is the new candidate, not a 54-cell replay.
# Wall clock is quantised by ceil(cells_per_task / workers), and a review
# task is 9 cells cold, so 4 costs host contention for exactly the wall
# clock of 3. The next step up that buys anything is 5 (3 waves -> 2).
name: GitNexus skill evolution name: GitNexus skill evolution
on: on:
@ -95,51 +68,21 @@ on:
required: false required: false
default: '3' default: '3'
type: string type: string
workers:
description: 'Benchmark cells of one task to run at once — 3 fits the evolution box; drop to 1 only if siblings hit the session ceiling'
required: false
default: '3'
type: string
model: model:
description: 'Model for the benchmark arms (match the model your skill users run)' description: 'Model for the benchmark arms (match the model your skill users run)'
required: false required: false
default: 'gpt-5.6-sol' default: 'claude-sonnet-5'
type: string type: string
proposer_model: proposer_model:
description: 'Model for the proposer/diagnosis session — a stronger model is fine (one session per generation)' description: 'Model for the proposer/diagnosis session — a stronger model is fine (one session per generation)'
required: false required: false
default: 'gpt-5.6-sol' default: 'claude-opus-4-8'
type: string type: string
effort:
description: 'Reasoning effort for every proposer and benchmark session'
required: false
default: xhigh
type: choice
options:
- low
- medium
- high
- xhigh
- max
provider:
description: 'Model backend. auto uses Anthropic when that secret exists; openai forces the loopback OpenAI gateway even if an Anthropic key is also configured.'
required: false
default: openai
type: choice
options:
- auto
- openai
- anthropic
include_expensive: include_expensive:
description: 'Include tasks marked expensive: true' description: 'Include tasks marked expensive: true'
required: false required: false
default: false default: false
type: boolean type: boolean
seed_from_previous:
description: "Seed the proposer with the previous run's evidence and rejected proposal. Turn off to start from a blank slate — required when the earlier evidence is not trustworthy (e.g. produced before a harness-integrity fix), since a tainted proposal would otherwise propagate into every later generation."
required: false
default: true
type: boolean
concurrency: concurrency:
group: ${{ github.workflow }} group: ${{ github.workflow }}
@ -154,105 +97,35 @@ jobs:
github.repository == 'abhigyanpatwari/GitNexus' && github.repository == 'abhigyanpatwari/GitNexus' &&
( (
github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_dispatch' ||
( vars.GITNEXUS_EVOLUTION_ENABLED == 'true'
vars.GITNEXUS_EVOLUTION_ENABLED == 'true' &&
vars.GITNEXUS_EVOLUTION_WORKERS == '3'
)
) )
runs-on: [self-hosted, linux, x64, gitnexus-evolution] runs-on: [self-hosted, linux, x64, gitnexus-evolution]
# Gate promotion runs on a protected Environment. An admin must attach a # Gate promotion runs on a protected Environment. An admin must attach a
# deployment-branch rule (main only) and ideally scope the model and App # deployment-branch rule (main only) and ideally scope the three secrets to
# secrets to it — server-side enforcement a dispatched non-main ref cannot bypass by # it — server-side enforcement a dispatched non-main ref cannot bypass by
# editing its own workflow copy. See the activation checklist above. # editing its own workflow copy. See the activation checklist above.
environment: gitnexus-evolution environment: gitnexus-evolution
# Three budgets have to nest, longest first, or the evidence is lost: timeout-minutes: 1440 # self-hosted ceiling is 5 days (7200min); 24h is a generous margin over a single-generation serial run
# EventBridge instance uptime (24h from ~02:45)
# > this job timeout (21h)
# > the benchmark step timeout (19h, set on the step below)
# A job-level timeout CANCELS the job, so the upload step never runs and a
# multi-hour generation's evidence dies with it; a step-level timeout only
# fails that step, and `if: always()` still uploads what the sweep wrote.
# The instance must outlive the job for the same reason — when the box
# stops the runner just disappears mid-step. Scheduled runs can start well
# after the cron (the 2026-08-01 run was queued 65min late), so the job
# budget has to absorb that delay and still land inside the uptime window.
# A Friday workflow_dispatch on a box that already booted for Saturday's
# cron inherits leftover uptime, not a fresh 24h. Run 33962002890 started
# Friday 10:57 UTC and vanished at the Saturday 03:00 stop — 51 finished
# sessions never uploaded. run-evolution.sh therefore passes
# --max-runtime-from-instance-window, and the CLI derives its cap from
# /proc/uptime at startup, so the sweep fails in-process and this always()
# upload still runs.
timeout-minutes: 1260
permissions: permissions:
contents: read # The promotion PR uses a short-lived App token minted below. contents: read # The promotion PR uses a short-lived App token minted below.
actions: read # Read the previous run's evidence artifact to seed the proposer.
env: env:
GENERATIONS: ${{ inputs.generations || '1' }} GENERATIONS: ${{ inputs.generations || '1' }}
RUNS: ${{ inputs.runs || '3' }} RUNS: ${{ inputs.runs || '3' }}
# A manual input wins; scheduled runs use the repository rollout knob. MODEL: ${{ inputs.model || 'claude-sonnet-5' }}
# Both fall back to serial — see workflow_bench.runner --workers for why. PROPOSER_MODEL: ${{ inputs.proposer_model || 'claude-opus-4-8' }}
WORKERS: ${{ inputs.workers || vars.GITNEXUS_EVOLUTION_WORKERS || '1' }}
MODEL: ${{ inputs.model || 'gpt-5.6-sol' }}
PROPOSER_MODEL: ${{ inputs.proposer_model || 'gpt-5.6-sol' }}
EFFORT: ${{ inputs.effort || 'xhigh' }}
PROVIDER: ${{ inputs.provider || 'openai' }}
INCLUDE_EXPENSIVE: ${{ inputs.include_expensive && '1' || '' }} INCLUDE_EXPENSIVE: ${{ inputs.include_expensive && '1' || '' }}
steps: steps:
- name: Require the benchmark auth secret - name: Require the benchmark auth secret
env: env:
HAS_ANTHROPIC: ${{ secrets.GITNEXUS_BENCH_ANTHROPIC_API_KEY != '' || secrets.GITNEXUS_BENCH_AUTH_TOKEN != '' }} HAS_TOKEN: ${{ secrets.GITNEXUS_BENCH_AUTH_TOKEN != '' }}
HAS_OPENAI: ${{ secrets.GITNEXUS_BENCH_OPENAI_API_KEY != '' }}
run: | run: |
set -euo pipefail set -euo pipefail
if [[ "${HAS_ANTHROPIC}" != 'true' && "${HAS_OPENAI}" != 'true' ]]; then if [[ "${HAS_TOKEN}" != 'true' ]]; then
echo '::error::Configure GITNEXUS_BENCH_ANTHROPIC_API_KEY (Anthropic API key, not the Claude Code OAuth token) and/or GITNEXUS_BENCH_OPENAI_API_KEY. The evolution loop runs real benchmark sessions.' echo '::error::GITNEXUS_BENCH_AUTH_TOKEN is not configured. The evolution loop runs real benchmark sessions and needs an Anthropic API key (not the Claude Code OAuth token).'
exit 1
fi
case "${PROVIDER}" in
openai)
if [[ "${HAS_OPENAI}" != 'true' ]]; then
echo '::error::provider=openai requires GITNEXUS_BENCH_OPENAI_API_KEY on the gitnexus-evolution environment.'
exit 1
fi
;;
anthropic)
if [[ "${HAS_ANTHROPIC}" != 'true' ]]; then
echo '::error::provider=anthropic requires GITNEXUS_BENCH_ANTHROPIC_API_KEY on the gitnexus-evolution environment.'
exit 1
fi
;;
auto)
;;
*)
echo "::error::Unknown provider '${PROVIDER}' (expected auto, openai, or anthropic)."
exit 1
;;
esac
- name: Verify runner survival policy
run: |
set -euo pipefail
needrestart_policy=/etc/needrestart/conf.d/90-gitnexus-evolution.conf
needrestart_line="\$nrconf{restart} = 'l';"
if [[ ! -r "${needrestart_policy}" ]] || ! grep -Fqx "${needrestart_line}" "${needrestart_policy}"; then
echo "::error::${needrestart_policy} must contain: ${needrestart_line}"
exit 1
fi
# The runner sets job processes to 500; the host oom-guard rewrites
# them to -900. Read once and the check loses that race.
oom_score_adjustment="$(</proc/self/oom_score_adj)"
deadline=$((SECONDS + 5))
while (( oom_score_adjustment > -900 && SECONDS < deadline )); do
sleep 0.05
oom_score_adjustment="$(</proc/self/oom_score_adj)"
done
if (( oom_score_adjustment > -900 )); then
echo "::error::Runner.Worker descendants require OOMScoreAdjust=-900 or stronger; effective value is ${oom_score_adjustment}."
exit 1 exit 1
fi fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
@ -265,33 +138,18 @@ jobs:
gitnexus/package-lock.json gitnexus/package-lock.json
gitnexus-shared/package-lock.json gitnexus-shared/package-lock.json
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with: with:
version: '0.11.23' version: '0.11.23'
python-version: '3.13' python-version: '3.13'
enable-cache: true enable-cache: true
cache-dependency-glob: eval/uv.lock cache-dependency-glob: eval/uv.lock
- name: Fetch pinned Compound Engineering review comparator
env:
CE_COMMIT: 3ad9b51bceecf0158e590c882034d0398dbb9c5c
run: |
set -euo pipefail
destination="${RUNNER_TEMP}/compound-engineering-plugin"
rm -rf "${destination}"
git clone --filter=blob:none --no-checkout \
https://github.com/EveryInc/compound-engineering-plugin.git "${destination}"
git -C "${destination}" checkout --detach "${CE_COMMIT}"
test "$(git -C "${destination}" rev-parse HEAD)" = "${CE_COMMIT}"
- name: Install sandbox runtime and pinned Claude CLI - name: Install sandbox runtime and pinned Claude CLI
run: | run: |
set -euo pipefail set -euo pipefail
# This box is stopped six days a week, so persistent apt timers can sudo apt-get update
# begin their catch-up run shortly after boot. Wait for dpkg instead sudo apt-get install --yes --no-install-recommends bubblewrap socat
# of racing the same package lock and failing the weekly lane.
sudo apt-get -o DPkg::Lock::Timeout=600 update
sudo apt-get -o DPkg::Lock::Timeout=600 install --yes --no-install-recommends bubblewrap ripgrep socat
apparmor_userns=/proc/sys/kernel/apparmor_restrict_unprivileged_userns apparmor_userns=/proc/sys/kernel/apparmor_restrict_unprivileged_userns
if [[ -r "${apparmor_userns}" ]] && [[ "$(<"${apparmor_userns}")" == '1' ]]; then if [[ -r "${apparmor_userns}" ]] && [[ "$(<"${apparmor_userns}")" == '1' ]]; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
@ -315,203 +173,78 @@ jobs:
test "$("${canary_runtime}/node_modules/@anthropic-ai/claude-code-linux-x64/claude" --version)" = \ test "$("${canary_runtime}/node_modules/@anthropic-ai/claude-code-linux-x64/claude" --version)" = \
'2.1.214 (Claude Code)' '2.1.214 (Claude Code)'
- name: Verify contained review execution before paid sessions
working-directory: eval
env:
GITNEXUS_REQUIRE_BWRAP_CANARY: '1'
GITNEXUS_REQUIRE_CLAUDE_CANARY: '1'
CLAUDE_CANARY_BIN: ${{ runner.temp }}/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude
run: |
set -euo pipefail
uv run --locked --extra dev python -m pytest tests/test_proposer_sandbox.py -q
- name: Install monorepo root dependencies - name: Install monorepo root dependencies
run: | run: |
set -euo pipefail set -euo pipefail
# The benchmark's task bindings sandbox-copy node_modules from the # The benchmark's task bindings sandbox-copy node_modules from the
# monorepo root as well as gitnexus-shared and gitnexus (see the # monorepo root as well as gitnexus-shared and gitnexus (see the
# sandbox_dependencies entries in tasks.scenarios.yaml). gitnexus # sandbox_copy entries in tasks.scenarios.yaml). The two steps below
# npm ci plus build compiles shared through parent lib/tsc.js; do # install the subpackage trees; the root tree needs its own install
# not npm ci gitnexus-shared (second TypeScript 7 optional install). # or capture_task_dependency_binding aborts at task binding on the
# missing root node_modules.
npm ci npm ci
- name: Build pinned shared runtime
run: |
set -euo pipefail
npm ci
npm run build
working-directory: gitnexus-shared
- name: Install and build pinned GitNexus runtime - name: Install and build pinned GitNexus runtime
run: | run: |
set -euo pipefail set -euo pipefail
npm ci npm ci
npm run build npm run build
# Task YAML still mounts gitnexus-shared/node_modules. Keep an empty
# directory so capture_task_dependency_binding does not abort, without
# installing TypeScript 7 inside shared.
mkdir -p ../gitnexus-shared/node_modules
working-directory: gitnexus working-directory: gitnexus
- name: Point the benchmark task repo at the checkout - name: Point the benchmark task repo at the checkout
run: | run: |
set -euo pipefail set -euo pipefail
# tasks.review.scenarios.yaml addresses the target repo as ~/GitNexus (the # tasks.scenarios.yaml addresses the target repo as ~/GitNexus (the
# developer-local convention). On the runner the repo is the checkout # developer-local convention). On the runner the repo is the checkout
# at ${GITHUB_WORKSPACE}; link it so runner_tasks.py can resolve the # at ${GITHUB_WORKSPACE}; link it so runner_tasks.py can resolve the
# task `repo` path. The benchmark only clones the repo (copy-on-write) # task `repo` path. The benchmark only clones the repo (copy-on-write)
# and mounts dependencies read-only, so the checkout is never mutated. # and mounts dependencies read-only, so the checkout is never mutated.
if [[ -e "${HOME}/GitNexus" && ! -L "${HOME}/GitNexus" ]]; then
echo '::error::~/GitNexus exists and is not a symlink; refusing to place the checkout inside it.'
exit 1
fi
ln -sfn "${GITHUB_WORKSPACE}" "${HOME}/GitNexus" ln -sfn "${GITHUB_WORKSPACE}" "${HOME}/GitNexus"
# The review corpus pins historical object ids. Fetch main so those
# objects are present even when actions/checkout selected another ref.
git -C "${GITHUB_WORKSPACE}" fetch --no-tags --quiet \
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" \
'+refs/heads/main:refs/remotes/origin/main'
baseline_sha="$(git -C "${GITHUB_WORKSPACE}" rev-parse --verify 'refs/remotes/origin/main^{commit}')"
echo "Fetched review corpus history at ${baseline_sha}"
- name: Seed the proposer with the previous run's evidence
id: seed
# Scheduled runs always seed; a dispatch can opt out to start clean.
if: github.event_name != 'workflow_dispatch' || inputs.seed_from_previous
# Best-effort seeding must not consume the benchmark's budget. This
# step walks up to 10 prior runs and every iteration blocks on network
# it does not control (`gh run download` of a multi-hundred-megabyte
# artifact). Unbounded, a wedged download sits here until the 21h job
# timeout CANCELS the job — and a cancelled job skips even
# `if: always()`, so the sweep never starts and nothing is uploaded.
# Bounding the step instead fails it in minutes, which is a loud,
# cheap, re-runnable failure rather than a silent 21h loss. 15 minutes
# is an order of magnitude above the observed walk (well under a
# minute) and a rounding error against the 19h sweep it protects.
timeout-minutes: 15
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Without this the weekly lane is memoryless: `--seed-results` is the
# only way a run sees what already lost (evolve stages the prior
# proposal when present and summarizes promotion.json when present),
# and with the default --generations 1 there is no earlier generation
# in-process to supply it. Every Saturday would otherwise propose
# from a blank slate and could re-propose the same rejected candidate
# forever. Best-effort by design: a first run, an expired artifact,
# or a download failure must not cost a whole generation.
if ! command -v gh >/dev/null; then
echo '::warning::gh is not installed on this runner — proposing without prior evidence. The promotion-PR step needs gh too.'
exit 0
fi
if ! previous_runs="$(gh run list \
--repo "${GITHUB_REPOSITORY}" \
--workflow gitnexus-skill-evolution.yml \
--branch main \
--status completed \
--limit 10 \
--json databaseId \
--jq "map(.databaseId) | map(select(. != ${GITHUB_RUN_ID})) | .[]")"; then
echo '::warning::Prior workflow runs could not be listed; proposing without prior evidence.'
exit 0
fi
if [[ -z "${previous_runs}" ]]; then
echo 'No prior completed run to seed from; the proposer starts from the learnings queue only.'
exit 0
fi
seed_root="${RUNNER_TEMP}/wfseed"
rm -rf "${seed_root}"
install -d -m 0700 "${seed_root}"
seed=''
# Failed sweeps deliberately upload partial evidence, so "completed"
# is the right population. Walk newest-first until one still-retained
# artifact actually contains benchmark rows; an empty latest run must
# not hide an older useful one.
for previous in ${previous_runs}; do
if [[ ! "${previous}" =~ ^[0-9]+$ ]]; then
echo "::warning::Ignoring malformed prior run id: ${previous}"
continue
fi
run_root="${seed_root}/${previous}"
install -d -m 0700 "${run_root}"
if ! gh run download "${previous}" --repo "${GITHUB_REPOSITORY}" --dir "${run_root}"; then
echo "::warning::Evidence from run ${previous} could not be downloaded (expired or absent); trying an older run."
continue
fi
unsafe="$(find "${run_root}" ! -type d ! -type f -print -quit)"
if [[ -n "${unsafe}" ]]; then
echo "::warning::Run ${previous} contains a non-regular artifact entry; trying an older run."
continue
fi
# upload-artifact normalizes directories/files to 0755/0644, while
# the evidence reader deliberately requires transcript paths to be
# owner-only. Restore that trust-boundary invariant after download.
if ! chmod -R go-rwx "${run_root}"; then
echo "::warning::Evidence permissions from run ${previous} could not be restricted; trying an older run."
continue
fi
# The artifact holds gen-N/bench/{results.jsonl,promotion.json,...};
# the highest generation is the one that actually reached the gate.
latest="$(find "${run_root}" -type f -path '*/gen-*/bench/results.jsonl' | sort -V | tail -1)"
if [[ -z "${latest}" || -L "${latest}" || ! -f "${latest}" ]]; then
echo "::warning::Run ${previous} uploaded no usable benchmark results; trying an older run."
continue
fi
# Existence is insufficient: an interrupted run may leave an empty,
# malformed, or session/infra-only JSONL. Reuse the same bounded
# selection and transcript/digest preflight the proposer will use,
# so an unusable newer run cannot hide an older useful one.
if uv run --project eval --locked --extra dev python -c \
'from pathlib import Path; import json, sys, tempfile; from workflow_bench.evolve import load_jsonl, select_evidence, stage_proposer_evidence_bundle, summarize_gate; result = Path(sys.argv[1]); root = result.parent; rows = select_evidence(load_jsonl(result)); rows or sys.exit(10); promotion = root / "promotion.json"; gate = summarize_gate(json.loads(promotion.read_text())) if promotion.is_file() else []; prior = root.parent / "proposal.md"; prior = prior if prior.is_file() and not prior.is_symlink() else None; dest = Path(tempfile.mkdtemp(prefix="wfseed-preflight-")) / "bundle"; stage_proposer_evidence_bundle(dest, results_dir=root, evidence=rows, learnings=[], gate_summary=gate, prior_proposal=prior)' \
"${latest}"; then
:
else
usability_status=$?
echo "::warning::Run ${previous} failed evidence preflight (exit ${usability_status}); trying an older run."
continue
fi
seed="$(dirname "${latest}")"
echo "Seeding the proposer from run ${previous}: ${seed}"
break
done
if [[ -z "${seed}" ]]; then
echo '::warning::No usable prior benchmark artifact found; proposing without prior evidence.'
exit 0
fi
echo "seed=${seed}" >> "${GITHUB_OUTPUT}"
- name: Run the propose → benchmark → gate loop - name: Run the propose → benchmark → gate loop
id: loop id: loop
# Kill the sweep with time left in the job to upload what it produced.
# See the budget nesting on the job above.
timeout-minutes: 1140
env: env:
GITNEXUS_BENCH_ANTHROPIC_API_KEY: ${{ secrets.GITNEXUS_BENCH_ANTHROPIC_API_KEY || secrets.GITNEXUS_BENCH_AUTH_TOKEN }} GITNEXUS_BENCH_AUTH_TOKEN: ${{ secrets.GITNEXUS_BENCH_AUTH_TOKEN }}
GITNEXUS_BENCH_OPENAI_API_KEY: ${{ secrets.GITNEXUS_BENCH_OPENAI_API_KEY }}
# The step's stdout is a pipe, so CPython block-buffers it and a
# multi-hour generation would report nothing until it exits (run
# 29907431284 emitted every line at the same timestamp, 14h45m in).
PYTHONUNBUFFERED: '1'
SEED_RESULTS: ${{ steps.seed.outputs.seed }}
EVOLUTION_PROFILE: review
CE_PLUGIN_DIR: ${{ runner.temp }}/compound-engineering-plugin
CE_PLUGIN_VERSION: 3.24.0
run: | run: |
set -euo pipefail set -euo pipefail
./workflow_bench/run-evolution.sh --apply out_root="${RUNNER_TEMP}/wfevolve"
echo "out_root=${out_root}" >> "${GITHUB_OUTPUT}"
extra=()
if [[ -n "${INCLUDE_EXPENSIVE}" ]]; then
extra+=(--include-expensive)
fi
uv run --locked --extra dev python -m workflow_bench.evolve \
--tasks workflow_bench/tasks.scenarios.yaml \
--model "${MODEL}" \
--proposer-model "${PROPOSER_MODEL}" \
--generations "${GENERATIONS}" \
--runs "${RUNS}" \
--claude-bin "${RUNNER_TEMP}/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude" \
--out-root "${out_root}" \
--apply \
"${extra[@]}"
working-directory: eval working-directory: eval
- name: Upload benchmark evidence - name: Upload benchmark evidence
# Unconditional: the sweep writes results.jsonl and transcripts as it if: always() && steps.loop.outputs.out_root != ''
# goes, so a killed or failed generation still has evidence worth
# keeping — and that is exactly the run whose evidence is needed.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: gitnexus-evolution-${{ github.run_id }}-${{ github.run_attempt }} name: gitnexus-evolution-${{ github.run_id }}-${{ github.run_attempt }}
# Addressed directly rather than carried from the sweep step: that is path: ${{ steps.loop.outputs.out_root }}
# the step whose death is the reason this upload matters, and a value
# threaded from it would not be there when it counts.
path: ${{ runner.temp }}/wfevolve
retention-days: 14 retention-days: 14
if-no-files-found: warn if-no-files-found: warn
- name: Detect and bound the applied promotion - name: Detect and bound the applied promotion
id: promotion id: promotion
env:
OUT_ROOT: ${{ steps.loop.outputs.out_root }}
run: | run: |
set -euo pipefail set -euo pipefail
changed="$(git status --porcelain)" changed="$(git status --porcelain)"
@ -526,7 +259,7 @@ jobs:
while IFS= read -r line; do while IFS= read -r line; do
path="${line:3}" path="${line:3}"
case "${path}" in case "${path}" in
.claude/skills/gitnexus-review/*|gitnexus/skills/gitnexus-review/*|gitnexus-claude-plugin/skills/gitnexus-review/*|gitnexus-cursor-integration/skills/gitnexus-review/*) ;; .claude/skills/*|gitnexus/skills/*|gitnexus-claude-plugin/skills/*) ;;
*) *)
echo "::error::Promotion touched a path outside the skill trees: ${path}" echo "::error::Promotion touched a path outside the skill trees: ${path}"
exit 1 exit 1
@ -540,7 +273,7 @@ jobs:
# generation's decisions could surface in the PR body. The heredoc # generation's decisions could surface in the PR body. The heredoc
# uses a per-run random delimiter so a summary value that ever # uses a per-run random delimiter so a summary value that ever
# contains the marker cannot close the block early and inject keys. # contains the marker cannot close the block early and inject keys.
promotion_file="$(find "${RUNNER_TEMP}/wfevolve" -name promotion.json | sort -V | tail -1)" promotion_file="$(find "${OUT_ROOT}" -name promotion.json | sort -V | tail -1)"
delim="PROMOTION_EOF_$(openssl rand -hex 16)" delim="PROMOTION_EOF_$(openssl rand -hex 16)"
{ {
echo "summary<<${delim}" echo "summary<<${delim}"
@ -583,7 +316,7 @@ jobs:
git config user.name 'gitnexus-evolution[bot]' git config user.name 'gitnexus-evolution[bot]'
git config user.email 'gitnexus-evolution[bot]@users.noreply.github.com' git config user.email 'gitnexus-evolution[bot]@users.noreply.github.com'
git checkout -b "${branch}" git checkout -b "${branch}"
git add .claude/skills gitnexus/skills gitnexus-claude-plugin/skills gitnexus-cursor-integration/skills/gitnexus-review git add .claude/skills gitnexus/skills gitnexus-claude-plugin/skills
git commit -m 'feat(skills): promoted evolution overlay (gate-passed)' git commit -m 'feat(skills): promoted evolution overlay (gate-passed)'
# The App token reaches git through GIT_ASKPASS reading step env at # The App token reaches git through GIT_ASKPASS reading step env at

View file

@ -44,7 +44,7 @@ jobs:
permissions: permissions:
contents: read contents: read
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false

View file

@ -37,7 +37,7 @@ jobs:
# artifact and never pushes; the default-persisted token in .git/config # artifact and never pushes; the default-persisted token in .git/config
# must not be capturable through that upload (zizmor credential-persistence # must not be capturable through that upload (zizmor credential-persistence
# / artipacked audit). Mirrors ci-tests.yml. # / artipacked audit). Mirrors ci-tests.yml.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false

View file

@ -336,7 +336,7 @@ jobs:
# Push auth is provided inline at push time via the URL. # Push auth is provided inline at push time via the URL.
- name: Checkout PR head - name: Checkout PR head
if: steps.locate.outputs.found == 'true' if: steps.locate.outputs.found == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v5.0.4
with: with:
repository: ${{ steps.locate.outputs.head_repo }} repository: ${{ steps.locate.outputs.head_repo }}
ref: ${{ steps.locate.outputs.head_sha }} ref: ${{ steps.locate.outputs.head_sha }}

View file

@ -121,35 +121,64 @@ jobs:
# metadata.json to reference another PR or SHA, redirecting our # metadata.json to reference another PR or SHA, redirecting our
# write-scoped sticky/check-run onto an attacker-chosen target. # write-scoped sticky/check-run onto an attacker-chosen target.
# #
# Authority is workflow_run.head_sha + head_repository.full_name + # Authority sources are all server-controlled GitHub event fields:
# head_branch, resolved via pulls?head={owner}:{branch}. That query # - workflow_run.head_sha
# works for same-repo PRs and forks; commits/{sha}/pulls is empty # - workflow_run.head_repository.full_name
# for fork SHAs. The script comes from THIS default-branch checkout # - workflow_run.pull_requests[].number (within-repo PRs only;
# (the same trust anchor as this workflow file). # empty array on fork PRs — fall back to commits/{sha}/pulls)
# #
# Always verify — including the changed_lines=0 path — so the
# check-run SHA cannot be an unverified artifact field.
# Mismatch => fail loud BEFORE any sticky/check-run side effect. # Mismatch => fail loud BEFORE any sticky/check-run side effect.
- name: Checkout identity verifier
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts/verify-workflow-run-pr-identity.cjs
sparse-checkout-cone-mode: false
path: trusted
- name: Verify metadata against workflow_run authority - name: Verify metadata against workflow_run authority
id: verify id: verify
if: steps.meta.outputs.changed_lines != '0'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }} GH_REPO: ${{ github.repository }}
META_PATH: autofix-in/metadata.json META_PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
SCHEMA_PATTERN: '^gitnexus\.pr-autofix/v[0-9]+$' META_HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
META_HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
WF_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} WF_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
WF_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} WF_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }}
WF_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} WF_PR_NUMBERS: ${{ toJSON(github.event.workflow_run.pull_requests.*.number) }}
shell: bash shell: bash
run: node trusted/.github/scripts/verify-workflow-run-pr-identity.cjs run: |
set -euo pipefail
# 1) head_sha must match exactly. workflow_run.head_sha is the
# commit GitHub actually ran the producer against — definitive.
if [ "${META_HEAD_SHA}" != "${WF_HEAD_SHA}" ]; then
echo "::error::Artifact head_sha (${META_HEAD_SHA}) does not match workflow_run.head_sha (${WF_HEAD_SHA}) — refusing to publish."
exit 1
fi
# 2) head_repo must match exactly. Same authority anchor.
if [ "${META_HEAD_REPO}" != "${WF_HEAD_REPO}" ]; then
echo "::error::Artifact head_repo (${META_HEAD_REPO}) does not match workflow_run.head_repository (${WF_HEAD_REPO}) — refusing to publish."
exit 1
fi
# 3) pr_number must reference an open PR with this head SHA.
# Within-repo PRs: workflow_run.pull_requests[] is populated.
# Fork PRs: that array is empty by GitHub design — fall back
# to the REST commit-to-PRs lookup. Fail closed if the lookup
# finds no matching open PR (avoids attacker-forged PR ids).
allowed_numbers=$(jq -c '.' <<< "${WF_PR_NUMBERS}")
if [ "${allowed_numbers}" = "[]" ]; then
echo "workflow_run.pull_requests is empty (fork PR) — falling back to commits/{sha}/pulls."
allowed_numbers=$(gh api "repos/${GH_REPO}/commits/${WF_HEAD_SHA}/pulls" \
--jq '[.[] | select(.state == "open") | .number]' 2>/dev/null || echo "[]")
if [ "${allowed_numbers}" = "[]" ]; then
echo "::error::No open PR found for head ${WF_HEAD_SHA} via commits/{sha}/pulls — refusing to publish."
exit 1
fi
fi
if ! jq -e --argjson n "${META_PR_NUMBER}" 'index($n) != null' <<< "${allowed_numbers}" >/dev/null; then
echo "::error::Artifact pr_number (${META_PR_NUMBER}) is not in the authoritative PR list (${allowed_numbers}) — refusing to publish."
exit 1
fi
echo "Verified: metadata identity matches workflow_run authority (PR=${META_PR_NUMBER}, head_sha=${META_HEAD_SHA}, head_repo=${META_HEAD_REPO})."
- name: Upsert sticky summary comment - name: Upsert sticky summary comment
# Only post when ci-quality found something fixable (= the # Only post when ci-quality found something fixable (= the
@ -158,14 +187,14 @@ jobs:
# so we skip it. # so we skip it.
if: >- if: >-
always() always()
&& steps.verify.outcome == 'success' && steps.meta.outputs.pr_number != ''
&& steps.meta.outputs.changed_lines != '0' && steps.meta.outputs.changed_lines != '0'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }} GH_REPO: ${{ github.repository }}
PR: ${{ steps.verify.outputs.pr_number }} PR: ${{ steps.meta.outputs.pr_number }}
CHANGED: ${{ steps.meta.outputs.changed_lines }} CHANGED: ${{ steps.meta.outputs.changed_lines }}
HEAD_SHA: ${{ steps.verify.outputs.head_sha }} HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
RUN_ID: ${{ github.run_id }} RUN_ID: ${{ github.run_id }}
shell: bash shell: bash
run: | run: |
@ -256,11 +285,11 @@ jobs:
# fixes-available → conclusion: neutral # fixes-available → conclusion: neutral
# `neutral` does not block branch-protection required-checks but # `neutral` does not block branch-protection required-checks but
# is visually distinct from a green pass. # is visually distinct from a green pass.
if: always() && steps.verify.outcome == 'success' if: always() && steps.meta.outputs.head_sha != ''
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }} GH_REPO: ${{ github.repository }}
HEAD_SHA: ${{ steps.verify.outputs.head_sha }} HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
CHANGED: ${{ steps.meta.outputs.changed_lines }} CHANGED: ${{ steps.meta.outputs.changed_lines }}
shell: bash shell: bash
run: | run: |

View file

@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# PR head commit (not the synthetic merge ref) — we need the # PR head commit (not the synthetic merge ref) — we need the
# exact tree the contributor pushed so suggestions line up. # exact tree the contributor pushed so suggestions line up.

View file

@ -162,7 +162,7 @@ jobs:
should_run: ${{ steps.decide.outputs.should_run }} should_run: ${{ steps.decide.outputs.should_run }}
head_sha: ${{ steps.decide.outputs.head_sha }} head_sha: ${{ steps.decide.outputs.head_sha }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true
@ -332,7 +332,7 @@ jobs:
# on the RC path. # on the RC path.
- name: Checkout (RC) - name: Checkout (RC)
if: needs.route.outputs.mode == 'rc' if: needs.route.outputs.mode == 'rc'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true
@ -349,7 +349,7 @@ jobs:
- name: Checkout (stable) - name: Checkout (stable)
if: needs.route.outputs.mode == 'stable' if: needs.route.outputs.mode == 'stable'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable # No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable
# path performs no git pushes; the default scope is sufficient. # path performs no git pushes; the default scope is sufficient.
with: with:
@ -692,20 +692,7 @@ jobs:
git add ../gitnexus-claude-plugin/.claude-plugin/plugin.json \ git add ../gitnexus-claude-plugin/.claude-plugin/plugin.json \
../.claude-plugin/marketplace.json \ ../.claude-plugin/marketplace.json \
../gitnexus-claude-plugin/.codex-plugin/plugin.json \ ../gitnexus-claude-plugin/.codex-plugin/plugin.json \
../.agents/plugins/marketplace.json \ ../.agents/plugins/marketplace.json
../gitnexus-factory-plugin/.factory-plugin/plugin.json \
../gitnexus-factory-plugin/mcp.json \
../.factory-plugin/marketplace.json \
../gitnexus-claude-plugin/skills/gitnexus-plan/mcp.json \
../gitnexus-claude-plugin/skills/gitnexus-work/mcp.json \
../gitnexus-claude-plugin/skills/gitnexus-review/mcp.json \
../gitnexus-claude-plugin/skills/gitnexus-lfg/mcp.json \
../gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json \
../gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json \
../gitnexus-claude-plugin/skills/gitnexus-debugging/mcp.json \
../gitnexus-claude-plugin/skills/gitnexus-exploring/mcp.json \
../gitnexus-claude-plugin/skills/gitnexus-impact-analysis/mcp.json \
../gitnexus-claude-plugin/skills/gitnexus-refactoring/mcp.json
git commit -m "release: ${VTAG}" --allow-empty git commit -m "release: ${VTAG}" --allow-empty
RELEASE_SHA="$(git rev-parse HEAD)" RELEASE_SHA="$(git rev-parse HEAD)"
echo "Detached release commit: $RELEASE_SHA" echo "Detached release commit: $RELEASE_SHA"
@ -844,7 +831,7 @@ jobs:
fi fi
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v2 uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v2
with: with:
tag_name: ${{ steps.vtag-gate.outputs.vtag }} tag_name: ${{ steps.vtag-gate.outputs.vtag }}
name: >- name: >-

View file

@ -33,7 +33,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
@ -53,6 +53,6 @@ jobs:
retention-days: 5 retention-days: 5
- name: Upload to Security tab - name: Upload to Security tab
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with: with:
sarif_file: results.sarif sarif_file: results.sarif

View file

@ -47,7 +47,7 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
# persist-credentials: false — runs a read-only test, never pushes. # persist-credentials: false — runs a read-only test, never pushes.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0

View file

@ -4,7 +4,7 @@ name: Tree-sitter Upgrade Readiness
# 1. Peer-dep compatibility — can each NPM-installed grammar install cleanly # 1. Peer-dep compatibility — can each NPM-installed grammar install cleanly
# with tree-sitter@0.25.0 without --legacy-peer-deps? # with tree-sitter@0.25.0 without --legacy-peer-deps?
# 2. Vendored grammars — each grammar in .github/vendored-grammars.json # 2. Vendored grammars — each grammar in .github/vendored-grammars.json
# (c/swift/kotlin/dart/proto/objc) is classified by its vendored ABI, read # (c/swift/kotlin/dart/proto) is classified by its vendored ABI, read
# straight from gitnexus/vendor/<name>/src/parser.c (NOT node_modules, # straight from gitnexus/vendor/<name>/src/parser.c (NOT node_modules,
# which is never populated for vendored grammars — that mismatch is why # which is never populated for vendored grammars — that mismatch is why
# the report used to render bare "?" placeholders, #858). # the report used to render bare "?" placeholders, #858).
@ -52,7 +52,7 @@ jobs:
report: ${{ steps.readiness.outputs.report }} report: ${{ steps.readiness.outputs.report }}
exit_code: ${{ steps.readiness.outputs.exit_code }} exit_code: ${{ steps.readiness.outputs.exit_code }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false

View file

@ -59,7 +59,7 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
sparse-checkout: .github/scripts/triage sparse-checkout: .github/scripts/triage
sparse-checkout-cone-mode: false sparse-checkout-cone-mode: false

View file

@ -45,15 +45,15 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Buildx - name: Setup Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build image (load locally for scan) - name: Build image (load locally for scan)
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with: with:
context: . context: .
file: ${{ matrix.image.dockerfile }} file: ${{ matrix.image.dockerfile }}
@ -76,7 +76,7 @@ jobs:
exit-code: '0' exit-code: '0'
- name: Upload to Security tab - name: Upload to Security tab
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with: with:
sarif_file: trivy-${{ matrix.image.name }}.sarif sarif_file: trivy-${{ matrix.image.name }}.sarif
category: trivy-${{ matrix.image.name }} category: trivy-${{ matrix.image.name }}

View file

@ -31,7 +31,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
@ -53,7 +53,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
@ -76,7 +76,7 @@ jobs:
continue-on-error: true continue-on-error: true
- name: Upload SARIF - name: Upload SARIF
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with: with:
sarif_file: zizmor.sarif sarif_file: zizmor.sarif
category: zizmor category: zizmor

11
.github/zizmor.yml vendored
View file

@ -18,11 +18,9 @@ rules:
# untrusted half (pr-autofix.yml) runs fork code with permissions:{} # untrusted half (pr-autofix.yml) runs fork code with permissions:{}
# and produces only a diff artifact (data, not executable code). The # and produces only a diff artifact (data, not executable code). The
# publish job consumes the artifact, allowlist-validates every field # publish job consumes the artifact, allowlist-validates every field
# of metadata.json, then cross-checks identity against # of metadata.json before exporting to $GITHUB_OUTPUT, never checks
# workflow_run.head_sha / head_repository / head_branch via # out fork code, and never executes anything fork-controlled. Header
# pulls?head=owner:branch (commits/{sha}/pulls is empty for fork SHAs). # comment in the file documents the split.
# It never checks out fork code and never executes anything
# fork-controlled. Header comment in the file documents the split.
- pr-autofix-publish.yml - pr-autofix-publish.yml
# workflow_run is the trusted half of the vendored-grammar prebuild # workflow_run is the trusted half of the vendored-grammar prebuild
@ -31,8 +29,7 @@ rules:
# validates the .node prebuilds and uploads them as artifacts. This # validates the .node prebuilds and uploads them as artifacts. This
# consumer downloads ONLY those artifacts + metadata.json, # consumer downloads ONLY those artifacts + metadata.json,
# allowlist-validates every metadata field, cross-checks identity against # allowlist-validates every metadata field, cross-checks identity against
# workflow_run.head_sha / head_repository / head_branch via # the workflow_run authority (head_sha / head_repo / pr_number), and
# pulls?head=owner:branch (commits/{sha}/pulls is empty for fork SHAs), and
# checks out the fork head pinned to that HEAD SHA solely to ADD prebuild # checks out the fork head pinned to that HEAD SHA solely to ADD prebuild
# files (never executes fork code) before pushing. Header comment in the # files (never executes fork code) before pushing. Header comment in the
# file documents the split. # file documents the split.

12
.gitignore vendored
View file

@ -31,7 +31,6 @@ npm-debug.log*
# Testing # Testing
coverage/ coverage/
.vitest/
.tmp-test/ .tmp-test/
gitnexus/.tmp-test/ gitnexus/.tmp-test/
@ -66,7 +65,6 @@ repomix-output*
# Playwright artifacts # Playwright artifacts
gitnexus-web/playwright-report/ gitnexus-web/playwright-report/
gitnexus-web/test-results/ gitnexus-web/test-results/
gitnexus-web/e2e/screenshots/
# Python test artifacts # Python test artifacts
eval/.coverage eval/.coverage
@ -74,8 +72,6 @@ eval/.hypothesis/
# Local docs — planning output (gitnexus-plan / gitnexus-work) stays local, not tracked # Local docs — planning output (gitnexus-plan / gitnexus-work) stays local, not tracked
docs/* docs/*
!docs/fork/
!docs/fork/**
gitnexus/test/fixtures/mini-repo/*.md gitnexus/test/fixtures/mini-repo/*.md
gitnexus/test/fixtures/mini-repo/.claude gitnexus/test/fixtures/mini-repo/.claude
@ -132,13 +128,5 @@ local_docs/
.context/ .context/
gitnexus/web/ gitnexus/web/
# Local copies of CI execution receipts.
gitnexus/benchmarks.json
gitnexus/preflight.json
gitnexus/test-results.json
gitnexus/windows-latest-*.json
gitnexus/macos-latest-*.json
gitnexus-web/web-test-results.json
# Machine-local skill-evolution evidence (consumed by eval/workflow_bench/evolve.py) # Machine-local skill-evolution evidence (consumed by eval/workflow_bench/evolve.py)
eval/workflow_bench/learnings.jsonl eval/workflow_bench/learnings.jsonl

View file

@ -1,28 +0,0 @@
# Keep Vercel uploads under the 100 MB file limit — SPA only needs web + shared sources.
.git
.gitnexus
.gitnexus/**
node_modules
**/node_modules
gitnexus/**
!gitnexus/package.json
eval
eval/**
.claude
.cursor
.github
docs
Documentation
.devcontainer
gitnexus-claude-plugin
gitnexus-cursor-integration
pr-swarm-review
ci-personas
*.sqlite*
*.db
dist
**/dist
coverage
**/coverage
playwright-report
test-results

View file

@ -1,7 +1,7 @@
<!-- version: 1.17.0 --> <!-- version: 1.14.0 -->
<!-- Last updated: 2026-09-24 --> <!-- Last updated: 2026-07-16 -->
Last reviewed: 2026-09-24 Last reviewed: 2026-07-16
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub) **Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
@ -39,7 +39,6 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
## Reference docs ## Reference docs
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)** - **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
- **Objective-C provider work:** read **[docs/languages/objective-c-provider.md](docs/languages/objective-c-provider.md)** before changing Objective-C parsing or resolution.
- **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro` → `MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.) - **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro` → `MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated. - **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
- **GitNexus:** standard skills in `.claude/skills/gitnexus-*/`; MCP rules in `gitnexus:start` block below. - **GitNexus:** standard skills in `.claude/skills/gitnexus-*/`; MCP rules in `gitnexus:start` block below.
@ -91,9 +90,6 @@ mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Toke
| Date | Version | Change | | Date | Version | Change |
|------|---------|--------| |------|---------|--------|
| 2026-09-24 | 1.17.0 | Clones with the same `origin` URL now share a store automatically; `--no-share` records a lasting opt-out (#3352). |
| 2026-09-24 | 1.16.0 | Documented the shared worktree index store (`<GITNEXUS_HOME>/stores/`, `analyze --share-with`, `GITNEXUS_SHARED_STORE=off`) in the storage notes (#3352). |
| 2026-09-07 | 1.15.0 | Added the Objective-C provider guide as the required reference before changing Objective-C parsing or resolution. |
| 2026-07-20 | 1.14.0 | `gitnexus-review` gains a coordinated swarm: six `ci-personas/` lanes the CI review agent dispatches as subagents (via the `Agent` tool), with a bounded critic gate and sidechain-excluded evidence. | | 2026-07-20 | 1.14.0 | `gitnexus-review` gains a coordinated swarm: six `ci-personas/` lanes the CI review agent dispatches as subagents (via the `Agent` tool), with a bounded critic gate and sidechain-excluded evidence. |
| 2026-07-16 | 1.13.0 | `gitnexus-plan` asks plan depth up front (quick/standard/deep) in interactive runs; `gitnexus-lfg` gate slimmed to proceed/stop (Deepen stays as the route-back mechanism). | | 2026-07-16 | 1.13.0 | `gitnexus-plan` asks plan depth up front (quick/standard/deep) in interactive runs; `gitnexus-lfg` gate slimmed to proceed/stop (Deepen stays as the route-back mechanism). |
| 2026-07-16 | 1.12.0 | Renamed `gitnexus-pr-review` to `gitnexus-review`; added PR URL/number, branch/range, and local-change targets plus install migration (setup warns on a legacy `gitnexus-pr-review` dir and leaves it in place; uninstall removes it). | | 2026-07-16 | 1.12.0 | Renamed `gitnexus-pr-review` to `gitnexus-review`; added PR URL/number, branch/range, and local-change targets plus install migration (setup warns on a legacy `gitnexus-pr-review` dir and leaves it in place; uninstall removes it). |
@ -118,7 +114,6 @@ mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Toke
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows).
> Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939). > Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939).
> On query/context/impact/cypher object results, read staleness.status and branch/lastCommit. Re-analyze only for behind or diverged — current is clone HEAD, not main.
## Always Do ## Always Do
@ -197,7 +192,6 @@ npx gitnexus serve # HTTP API on port 4747 (from any ind
### Gotchas ### Gotchas
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (`build-tree-sitter-grammars.cjs` activates committed prebuilds in place under `vendor/`, and only source-builds when none matches). A C/C++ toolchain (`python3`, `make`, `g++`) is needed only for that source-build fallback. - `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (materializes the vendored grammars into `node_modules/`, then prefers a committed prebuild per platform-arch and only source-builds when none matches). A C/C++ toolchain (`python3`, `make`, `g++`) is needed only for that source-build fallback.
- The vendored grammars `tree-sitter-{c,dart,proto,swift,kotlin,zig}` are handled uniformly: c is required; dart/proto/swift/kotlin/zig are optional and skippable via `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`. Install warnings appear only when no prebuild matches the platform-arch and no toolchain is present, and are non-fatal — only that language's parsing is unavailable. - The vendored grammars `tree-sitter-{c,dart,proto,swift,kotlin}` are handled uniformly: c is required; dart/proto/swift/kotlin are optional and skippable via `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`. Install warnings appear only when no prebuild matches the platform-arch and no toolchain is present, and are non-fatal — only that language's parsing is unavailable.
- ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`. - ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`.
- Index storage defaults to `<repo>/.gitnexus/`. `GITNEXUS_STORAGE_PATH` selects one complete external index directory and wins over `GITNEXUS_STORAGE_ROOT`, which creates an isolated `<repo-basename>-<12-hex>/` slot per repository. Linked worktrees share one store under `<GITNEXUS_HOME>/stores/<key>/` (one immutable graph per commit, private graphs for checkouts with local changes, shared parse caches); clones with the same `origin` URL join a registered sibling's store automatically (`analyze --share-with` names one, `--no-share` opts out and is remembered), and `GITNEXUS_SHARED_STORE=off` or either storage env var disables sharing (#3352). `GITNEXUS_CONTENT_RETENTION` is `full` (default), `symbol`, or `none`. MCP `list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`; MCP `include_content` may still return symbol spans at `symbol`.

View file

@ -24,7 +24,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
- **HTTP bridge:** `serve.ts` → Express (`api.ts`, `mcp-http.ts`) for web UI - **HTTP bridge:** `serve.ts` → Express (`api.ts`, `mcp-http.ts`) for web UI
- **CLI direct:** `gitnexus query|context|impact|cypher` in `tool.ts` - **CLI direct:** `gitnexus query|context|impact|cypher` in `tool.ts`
4. **Staleness** — `core/git-staleness.ts` compares indexed `lastCommit` to `HEAD` and classifies the result as `current`, `behind`, `diverged` (HEAD moved off the indexed commit, gap uncountable) or `unknown`; `core/staleness-status.ts` builds the one `staleness` payload that MCP `list_repos`, the read tools and the `serve` repo routes all emit. 4. **Staleness** — `staleness.ts` compares indexed `lastCommit` to `HEAD`, surfaces hints.
## MCP tools ## MCP tools
@ -377,7 +377,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
## Language-agnostic graph feeding ## Language-agnostic graph feeding
18 languages → single unified graph. Four abstraction layers: 16 languages → single unified graph. Four abstraction layers:
``` ```
Unified Graph Schema (44 node types, 21 relationship types) Unified Graph Schema (44 node types, 21 relationship types)
@ -405,7 +405,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) | | `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
| `definitionPropertiesExtractor` | Optional language-owned hook for structured, clone-safe definition metadata. Shared ingestion persists these properties opaquely; the owning provider supplies the extraction semantics. | | `definitionPropertiesExtractor` | Optional language-owned hook for structured, clone-safe definition metadata. Shared ingestion persists these properties opaquely; the owning provider supplies the extraction semantics. |
18 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error. 16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
### Unified capture tags ### Unified capture tags
@ -485,34 +485,14 @@ CLI (analyze.ts) → runFullAnalysis(repoPath, options, callbacks)
├── lbug.wal # Write-ahead log ├── lbug.wal # Write-ahead log
├── lbug.shadow # Shadow sidecar (checkpoint staging) ├── lbug.shadow # Shadow sidecar (checkpoint staging)
├── lbug.lock # Single-writer lock ├── lbug.lock # Single-writer lock
├── lbug.wal.checkpoint, lbug.checkpoint.{intent,apply}.lock # checkpoint-in-flight artifacts; left behind only by an interrupted checkpoint, consumed by the next writable open
├── lbug.{wal,shadow}.dirty-recovery # parked sidecars from a crashed run; safe to delete ├── lbug.{wal,shadow}.dirty-recovery # parked sidecars from a crashed run; safe to delete
├── gitnexus.json # lastCommit, indexedAt, stats (primary metadata file) ├── gitnexus.json # lastCommit, indexedAt, stats (primary metadata file)
└── meta.json # legacy mirror of gitnexus.json, kept in sync (see MIGRATION.md) └── meta.json # legacy mirror of gitnexus.json, kept in sync (see MIGRATION.md)
~/.gitnexus/ ~/.gitnexus/
├── registry.json # Global repo registry (MCP discovery) └── registry.json # Global repo registry (MCP discovery)
└── stores/<key>/ # Shared sibling index store (see below)
├── caches/ # parse cache + durable ParsedFile store
├── commits/<commit>-<featureKey>/ # one immutable graph per commit + settings
└── checkouts/<slot>/ # one checkout's metadata, membership, and
# private graph when it has local edits
``` ```
The flat `<repo>/.gitnexus/` layout applies to a standalone repository and
whenever `GITNEXUS_STORAGE_PATH` / `GITNEXUS_STORAGE_ROOT` is set. A repository
with linked worktrees, and clones with the same `origin` URL, share one
`stores/<key>/` automatically (a clone opts out with `analyze --no-share`;
`GITNEXUS_SHARED_STORE=off` turns sharing off entirely). Each sharing checkout
keeps only a `.gitnexus/store.json` pointer to its store. Path resolution lives
in `shared-store.ts`.
Read-only opens self-heal an interrupted checkpoint: the refusal is
classified and cleared by one writable open (probe + `CHECKPOINT`) before
the read-only open is retried — see `sidecar-recovery.ts`
(`isReadOnlyCheckpointInProgressError`) and the
`lbug-interrupted-checkpoint-recovery` integration test.
Managed by `repo-manager.ts`. Managed by `repo-manager.ts`.
## LadybugDB schema ## LadybugDB schema
@ -562,5 +542,4 @@ Node IDs use arity suffix (`#<paramCount>`): `Method:file:Class.method#1` vs `#2
- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery - [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents - [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents
- [TESTING.md](TESTING.md) — how to run tests - [TESTING.md](TESTING.md) — how to run tests
- [docs/languages/objective-c-provider.md](docs/languages/objective-c-provider.md) — Objective-C provider behavior and limits
- `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage - `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage

View file

@ -65,7 +65,6 @@ See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.m
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows).
> Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939). > Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939).
> On query/context/impact/cypher object results, read staleness.status and branch/lastCommit. Re-analyze only for behind or diverged — current is clone HEAD, not main.
## Always Do ## Always Do

View file

@ -16,19 +16,14 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro
**Prerequisites:** Node.js — `gitnexus/` requires `^22.18.0 || >=24.11.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version. **Prerequisites:** Node.js — `gitnexus/` requires `^22.18.0 || >=24.11.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
1. Clone the repository. 1. Clone the repository.
2. **CLI / MCP package:** `cd gitnexus && npm install && npm run build` 2. **Shared package:** `cd gitnexus-shared && npm install && npm run build`
`prepare` / `scripts/build.js` compiles `gitnexus-shared` with 3. **CLI / MCP package:** `cd ../gitnexus && npm install && npm run build`
`node …/typescript/lib/tsc.js` from this package. Do not `npm install` or 4. **Web UI (if needed):** `cd ../gitnexus-web && npm install`
`npm ci` inside `gitnexus-shared/` — that is a second TypeScript 7 5. Run tests as described in [TESTING.md](TESTING.md).
optional-platform install and is not what `setup-gitnexus` does.
3. **Web UI (if needed):** `cd gitnexus-web && npm install`
If you skipped step 2, compile shared with the web compiler first:
`cd gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js`
4. Run tests as described in [TESTING.md](TESTING.md).
The CLI build imports `gitnexus-shared`, so `gitnexus-shared/dist` must exist The CLI build imports `gitnexus-shared`, so a fresh clone must install and build
before `gitnexus` typecheck. That emit uses a parent package's TypeScript 7 the shared package before running `npm install` in `gitnexus/`. This is the same
`lib/tsc.js`, matching `setup-gitnexus`, `setup-gitnexus-web`, and Vercel. order used by the repository's `setup-gitnexus` CI action.
### Containerized development (optional) ### Containerized development (optional)
@ -75,7 +70,7 @@ Commits within a PR may use any style — only the **merged PR title** shows up
## Before you open a PR ## Before you open a PR
- [ ] Tests pass for the packages you touched (`gitnexus` and/or `gitnexus-web`). - [ ] Tests pass for the packages you touched (`gitnexus` and/or `gitnexus-web`).
- [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`. Those commands use TypeScript 7. The web app tsconfig lists `lib` `DOM`/`DOM.Iterable` and `jsx: react-jsx` so React/JSX typecheck on 7; Vite/`vitest` keep `@vitejs/plugin-react` with the automatic JSX runtime. Build `gitnexus-shared/dist` first with a parent `lib/tsc.js` (a `gitnexus` install/`npm run build` does this). Repo ESLint stays syntax-only on a TypeScript 5.x peer until typescript-eslint supports 7. - [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`.
- [ ] No secrets, tokens, or machine-specific paths committed. - [ ] No secrets, tokens, or machine-specific paths committed.
- [ ] Documentation updated if behavior or public CLI/MCP contract changes. - [ ] Documentation updated if behavior or public CLI/MCP contract changes.
- [ ] Every new `GITNEXUS_*` environment variable has a row in the **Environment variables** table in [README.md](README.md) — variable, default, effect, and when to tune it. - [ ] Every new `GITNEXUS_*` environment variable has a row in the **Environment variables** table in [README.md](README.md) — variable, default, effect, and when to tune it.

6
DoD.md
View file

@ -113,7 +113,7 @@ Run the commands relevant to the touched area. If something cannot be run in the
### 4.1 Build ordering ### 4.1 Build ordering
- [ ] `gitnexus-shared/` dist is built before consuming packages are typechecked or tested (CI uses the `setup-gitnexus` action, which compiles shared with the parent TypeScript 7 `lib/tsc.js` — local runs must match). - [ ] `gitnexus-shared/` dist is built before consuming packages are typechecked or tested (CI uses the `setup-gitnexus` action for this — local runs must match).
### 4.2 If `gitnexus/` changed ### 4.2 If `gitnexus/` changed
@ -123,13 +123,13 @@ Run the commands relevant to the touched area. If something cannot be run in the
### 4.3 If `gitnexus-web/` changed ### 4.3 If `gitnexus-web/` changed
- [ ] `cd gitnexus-web && npx tsc -b --noEmit` (TypeScript 7 typechecks React/JSX: `jsx: react-jsx`, `lib` includes `DOM`) - [ ] `cd gitnexus-web && npx tsc -b --noEmit`
- [ ] `cd gitnexus-web && npm test` - [ ] `cd gitnexus-web && npm test`
- [ ] `cd gitnexus-web && npm run test:e2e` when browser flows or user-facing UI behavior changed - [ ] `cd gitnexus-web && npm run test:e2e` when browser flows or user-facing UI behavior changed
### 4.4 If `gitnexus-shared/` changed ### 4.4 If `gitnexus-shared/` changed
- [ ] Shared package builds cleanly from a parent TypeScript 7 shim after that parent is installed (`cd gitnexus-shared && node ../gitnexus/node_modules/typescript/lib/tsc.js`, or `node ../gitnexus-web/node_modules/typescript/lib/tsc.js` after a web install). Do not `npm install` / `npm ci` in `gitnexus-shared/` for this check. - [ ] Shared package builds cleanly (`npm run build` in `gitnexus-shared/`)
- [ ] Dependent packages still typecheck and test after the shared change — verify both CLI and web consumers together - [ ] Dependent packages still typecheck and test after the shared change — verify both CLI and web consumers together
### 4.5 If CI workflows or release pipelines changed ### 4.5 If CI workflows or release pipelines changed

View file

@ -6,11 +6,8 @@ ARG TARGETPLATFORM
ARG NPM_VERSION=11.14.1 ARG NPM_VERSION=11.14.1
# -- Builder ----------------------------------------------------------- # -- Builder -----------------------------------------------------------
# Native modules (tree-sitter-*, node-gyp builds for # Native modules (tree-sitter-*, onnxruntime-node, node-gyp builds for
# tree-sitter-proto / tree-sitter-swift) require python3 + a C/C++ toolchain. # tree-sitter-proto / tree-sitter-swift) require python3 + a C/C++ toolchain.
# onnxruntime-node is not installed by `npm ci`; local embeddings need
# `gitnexus embeddings install` (or HTTP env / a bind-mounted prefix). The
# runtime stage strips npm, so this image cannot auto-heal the stack.
# node:22-bookworm-slim # node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS builder FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS builder
ARG NPM_VERSION ARG NPM_VERSION
@ -55,9 +52,8 @@ RUN npm run postinstall --prefix gitnexus
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
# curl for the healthcheck; git for cloning; procps for watch process identity; # curl for the healthcheck; git for cloning; procps for watch process identity;
# ca-certificates for TLS verification; openssh-client so auto-sync SSH remotes # ca-certificates for TLS verification.
# can clone (git invokes `ssh`; --no-install-recommends omits it from git). RUN apt-get update && apt-get install -y --no-install-recommends curl git procps ca-certificates && rm -rf /var/lib/apt/lists/* \
RUN apt-get update && apt-get install -y --no-install-recommends curl git procps ca-certificates openssh-client && rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm \ && rm -rf /usr/local/lib/node_modules/npm \
&& rm -rf /usr/local/lib/node_modules/corepack \ && rm -rf /usr/local/lib/node_modules/corepack \
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack

110
README.md
View file

@ -1,4 +1,6 @@
# GitNexus # GitNexus (Akon Labs)
**⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
<div align="center"> <div align="center">
@ -24,7 +26,7 @@
</a> </a>
</p> </p>
<p><strong>The context engine for Enterprise Codebases</strong></p> <p><strong>The nervous system for agent context.</strong></p>
<p> <p>
Indexes any codebase into a knowledge graph — every dependency, call chain, cluster, and execution flow — Indexes any codebase into a knowledge graph — every dependency, call chain, cluster, and execution flow —
@ -34,6 +36,7 @@
<p> <p>
💬 <a href="https://discord.gg/MgJrmsqr62">Discord</a> · 💬 <a href="https://discord.gg/MgJrmsqr62">Discord</a> ·
🌐 <a href="https://gitnexus.vercel.app">Web UI</a> · 🌐 <a href="https://gitnexus.vercel.app">Web UI</a> ·
🏢 <a href="https://akonlabs.com">Enterprise (SaaS & self-hosted)</a>
</p> </p>
</div> </div>
@ -71,7 +74,7 @@ That's it. `analyze` indexes the codebase, installs agent skills, registers Clau
> **No C++ toolchain?** Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` — those four languages won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild. > **No C++ toolchain?** Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` — those four languages won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
> **Local embeddings are opt-in.** Default `npm install` does not fetch `@huggingface/transformers` or `onnxruntime-node`. Run `gitnexus embeddings install` (or `gitnexus analyze --embeddings`, which auto-heals) to fetch the stack through your npm registry config into `~/.gitnexus/embedding-runtime`. CUDA GPU binaries still use NuGet via `--cuda` ([#2370](https://github.com/abhigyanpatwari/GitNexus/issues/2370)). The prefix needs Node with `module.registerHooks` (≥ 22.15 on 22.x, ≥ 23.5 on 23.x). A leftover 1.6.12 package-first tree is residual until a clean reinstall; `--force` only refreshes prefix overrides. > **Behind an HTTP proxy / regional firewall?** `onnxruntime-node`'s postinstall downloads optional CUDA binaries from `api.nuget.org` and ignores `HTTP_PROXY`/`HTTPS_PROXY` ([#2370](https://github.com/abhigyanpatwari/GitNexus/issues/2370)). The embedding stack is an optional dependency, so a failed download no longer breaks the install — and it self-heals: the first `gitnexus analyze --embeddings` (or `gitnexus embeddings install`) fetches the stack through your npm registry config (mirrors/proxies apply, no NuGet) into `~/.gitnexus/embedding-runtime` (override with `GITNEXUS_EMBEDDING_RUNTIME_DIR`). The on-demand prefix needs Node with `module.registerHooks` (≥ 22.15 on 22.x, ≥ 23.5 on 23.x); on older Node, keep the stack in the install itself with `ONNXRUNTIME_NODE_INSTALL=skip npm install -g gitnexus` (works on every supported Node).
> **About `tree-sitter-kotlin`:** like Dart/Proto/Swift, Kotlin is a **vendored** grammar (under `gitnexus/vendor/tree-sitter-kotlin`). Upstream ships **source only** (no prebuilt binaries), so GitNexus cross-builds the platform prebuilds itself (via the `build-tree-sitter-prebuilds` GitHub Actions workflow) and vendors them — the same uniform pipeline used for Dart, Proto, and Swift. `node-gyp-build` selects the right `.node` at require time, so **no C/C++ toolchain is needed**. If no prebuild matches your platform-arch, only Kotlin (`.kt`/`.kts`) parsing is unavailable; the rest of `gitnexus` is unaffected. > **About `tree-sitter-kotlin`:** like Dart/Proto/Swift, Kotlin is a **vendored** grammar (under `gitnexus/vendor/tree-sitter-kotlin`). Upstream ships **source only** (no prebuilt binaries), so GitNexus cross-builds the platform prebuilds itself (via the `build-tree-sitter-prebuilds` GitHub Actions workflow) and vendors them — the same uniform pipeline used for Dart, Proto, and Swift. `node-gyp-build` selects the right `.node` at require time, so **no C/C++ toolchain is needed**. If no prebuild matches your platform-arch, only Kotlin (`.kt`/`.kts`) parsing is unavailable; the rest of `gitnexus` is unaffected.
@ -99,10 +102,6 @@ The proxy strips `Origin` before forwarding, so the server's CSRF guard does not
Indexing is memory-bound. If `gitnexus-server` runs out of memory on a large repo, raise its `plan`, which sets available RAM: `standard` is 2 GB, `pro` is 4 GB. Raise `sizeGB` only if the disk fills with clones and indexes. Indexing is memory-bound. If `gitnexus-server` runs out of memory on a large repo, raise its `plan`, which sets available RAM: `standard` is 2 GB, `pro` is 4 GB. Raise `sizeGB` only if the disk fills with clones and indexes.
### Deploy to RepoCloud
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploylobe.svg)](https://repocloud.io/details/gitnexus/)
## Two Ways to Use GitNexus ## Two Ways to Use GitNexus
| | **CLI + MCP** (recommended) | **Web UI** | | | **CLI + MCP** (recommended) | **Web UI** |
@ -158,7 +157,7 @@ flowchart TB
## What Your AI Agent Gets ## What Your AI Agent Gets
### 19 MCP tools (17 per-repo + 2 group) ### 17 MCP tools (15 per-repo + 2 group)
| Tool | What It Does | | Tool | What It Does |
| ---------------- | ---------------------------------------------------------------------- | | ---------------- | ---------------------------------------------------------------------- |
@ -177,12 +176,10 @@ flowchart TB
| `api_impact` | Pre-change impact report for an API route handler | | `api_impact` | Pre-change impact report for an API route handler |
| `explain` | Explain persisted taint findings (source→sink flows, `--pdg` indexes) | | `explain` | Explain persisted taint findings (source→sink flows, `--pdg` indexes) |
| `pdg_query` | Query control/data dependence at statement level (`--pdg` indexes) | | `pdg_query` | Query control/data dependence at statement level (`--pdg` indexes) |
| `read_file` | Read a checkout file (optional 0-indexed slice; `maxLines` cap) |
| `grep` | Regex search of the working tree for indexed files (1-based hits) |
| `group_list` | List configured repository groups | | `group_list` | List configured repository groups |
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links | | `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
> Per-repo read-only tools take an optional `repo` parameter. Omit it when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout; otherwise pass it explicitly. Mutating tools require `repo` when multiple repos are indexed and no MCP default exists. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`, except `read_file` and `grep`, which read the checkout and do not accept `branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`. > Per-repo read-only tools take an optional `repo` parameter. Omit it when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout; otherwise pass it explicitly. Mutating tools require `repo` when multiple repos are indexed and no MCP default exists. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
### Resources for instant context ### Resources for instant context
@ -235,13 +232,12 @@ When a repo contains an `.agents/` directory, the standard and generated skills
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** | | **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/))[¹](#fn-antigravity-hooks) | **Full** | | **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/))[¹](#fn-antigravity-hooks) | **Full** |
| **Codex** | Yes | Yes | Yes (PreToolUse + PostToolUse, [Codex hooks](https://developers.openai.com/codex/hooks)) | **Full** | | **Codex** | Yes | Yes | Yes (PreToolUse + PostToolUse, [Codex hooks](https://developers.openai.com/codex/hooks)) | **Full** |
| **Factory** (Droid) | Yes | Yes | Yes (PostToolUse, [plugin](gitnexus-factory-plugin/)) | **Full** |
| **OpenCode** | Yes | Yes | — | MCP + Skills | | **OpenCode** | Yes | Yes | — | MCP + Skills |
| **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills | | **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills |
| **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills | | **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP | | **Windsurf** | Yes | — | — | MCP |
> **Full** means MCP tools + agent skills + hooks that enrich searches with graph context. **Claude Code** and **Codex** go deepest: their PreToolUse hooks enrich the search before it runs, and their PostToolUse hooks also detect a stale index after commits and prompt the agent to reindex. **Cursor**, **Antigravity**, and **Factory** augment from a post-tool hook only, so they enrich the result rather than the query and do not carry the stale-index hint. > **Claude Code** and **Codex** get the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex.
<a id="fn-antigravity-hooks"></a> <a id="fn-antigravity-hooks"></a>
@ -285,21 +281,6 @@ codex plugin marketplace add abhigyanpatwari/GitNexus
> **Codex notes:** SessionStart is intentionally not registered — Codex reads [AGENTS.md natively](https://developers.openai.com/codex/guides/agents-md), which already carries the GitNexus context block. Newly installed hooks need a one-time approval in Codex via `/hooks` before they run. Pick **one** install route (`gitnexus setup -c codex` **or** the plugin): plugin hooks load alongside `~/.codex/hooks.json`, so installing both can fire duplicate hooks per tool call. > **Codex notes:** SessionStart is intentionally not registered — Codex reads [AGENTS.md natively](https://developers.openai.com/codex/guides/agents-md), which already carries the GitNexus context block. Newly installed hooks need a one-time approval in Codex via `/hooks` before they run. Pick **one** install route (`gitnexus setup -c codex` **or** the plugin): plugin hooks load alongside `~/.codex/hooks.json`, so installing both can fire duplicate hooks per tool call.
**Factory** (Droid) — MCP + skills via `gitnexus setup -c droid`, or add the server manually to `~/.factory/mcp.json` ([user scope](https://docs.factory.ai/cli/configuration/mcp), applies to all projects):
```json
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}
```
`gitnexus setup -c droid` also installs skills to `~/.factory/skills/`. For the PostToolUse search-augment hook, install the bundled [`gitnexus-factory-plugin/`](gitnexus-factory-plugin/) — from a marketplace that includes this repo, run `droid plugin install gitnexus@<marketplace>`, or point Droid at it via `extraKnownMarketplaces` in `.factory/settings.json`. Factory reads [`AGENTS.md` natively](https://docs.factory.ai/), which already carries the GitNexus context block.
**Cursor** (`~/.cursor/mcp.json` — global, works for all projects): **Cursor** (`~/.cursor/mcp.json` — global, works for all projects):
```json ```json
@ -424,8 +405,7 @@ backoff. Invalid `.gitnexusrc` or ignore-file reloads pause ordinary refreshes
until the control file is fixed. Stop the watcher with Ctrl+C. until the control file is fixed. Stop the watcher with Ctrl+C.
Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`, Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`,
`--max-file-size`, `--max-processes`, `--max-process-branching`, `--max-file-size`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and
`--max-process-trace-depth`, `--max-entry-point-candidates`, `--branch`, `--pdg`, `--skip-fts`, `--name`, `--allow-duplicate-name`, and
`--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`, `--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`,
embedding flags, `--skills`, `--self-commit`, `--index-only`, and `--skip-git` embedding flags, `--skills`, `--self-commit`, `--index-only`, and `--skip-git`
are rejected. Unsupported defaults from `.gitnexusrc` are ignored with a are rejected. Unsupported defaults from `.gitnexusrc` are ignored with a
@ -459,7 +439,6 @@ The token may be set in the shell, `.env.local`, or `.env` in the working direct
gitnexus analyze --force # Full graph + FTS rebuild (reuses unchanged parser output) gitnexus analyze --force # Full graph + FTS rebuild (reuses unchanged parser output)
gitnexus analyze --no-parse-cache # Full rebuild that re-parses every source file gitnexus analyze --no-parse-cache # Full rebuild that re-parses every source file
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --skip-fts # Index graph/embeddings without loading FTS or building keyword indexes
gitnexus analyze --skills # Generate repo-specific skill files from detected communities gitnexus analyze --skills # Generate repo-specific skill files from detected communities
gitnexus analyze --skip-embeddings # Skip embedding generation (faster) gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, better search) gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, better search)
@ -471,17 +450,12 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16, gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16,
# auto-sized to the repo). 0 is rejected — there is no sequential mode. # auto-sized to the repo). 0 is rejected — there is no sequential mode.
gitnexus analyze --max-processes <n> # Process-detection process cap (replaces dynamic max(20, round(symbols/10)))
gitnexus analyze --max-entry-point-candidates <n> # Ranked entry-point pool (default 200; raise when the warning names it)
gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots
gitnexus analyze --asyncapi-spec ./docs/asyncapi # Resolve broker addresses from AsyncAPI 3.x documents gitnexus analyze --asyncapi-spec ./docs/asyncapi # Resolve broker addresses from AsyncAPI 3.x documents
gitnexus analyze --memory-budget 3000 # Main-thread V8 heap in MB (>= 200); overrides the auto-sizer and --max-old-space-size
gitnexus analyze --wal-checkpoint-threshold 67108864 # LadybugDB WAL auto-checkpoint threshold in bytes gitnexus analyze --wal-checkpoint-threshold 67108864 # LadybugDB WAL auto-checkpoint threshold in bytes
# (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB) # (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
``` ```
`--skip-fts` (or `GITNEXUS_SKIP_FTS=1`) disables FTS extension loading and keyword-index construction for this analysis. Graph queries, communities, processes, and existing embeddings remain available. Status and search report "FTS disabled for this index". Remove both the flag and environment setting and run `analyze` again to restore keyword search, even at the same commit. Only the exact environment value `1` enables the opt-out; the flag takes precedence. It cannot be combined with `--repair-fts`. Disabling an existing FTS index may require one graph-store rebuild to avoid unsafe writes through native indexes.
`--spring-actuator` is explicitly opt-in and accepts either a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. It confirms matching static nodes and adds conservative runtime-only routes, beans, and property keys. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Because snapshots are external runtime state, an enabled run always rebuilds; the first later run without the option rebuilds once to remove runtime evidence. The same path can be set as `springActuator` in `.gitnexusrc`. `--spring-actuator` is explicitly opt-in and accepts either a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. It confirms matching static nodes and adds conservative runtime-only routes, beans, and property keys. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Because snapshots are external runtime state, an enabled run always rebuilds; the first later run without the option rebuilds once to remove runtime evidence. The same path can be set as `springActuator` in `.gitnexusrc`.
`--asyncapi-spec` is explicitly opt-in and accepts a directory of AsyncAPI documents or a single document; the path is resolved against the repository root, so a committed `docs/asyncapi` and an absolute cache written by something else both work. Each `operations[]` entry of an **AsyncAPI 3.x** document can contribute a `Destination` node keyed by broker and address, with `action: send` emitting `PUBLISHES_TO` and `action: receive` emitting `CONSUMES_FROM`, so a document and source code that name one address on one broker land on the same node. Edges start at the document, not at a callable — a document states that the service talks to an address, not which method does — and no address a document names is ever attached to an unresolved source site. `--asyncapi-spec` is explicitly opt-in and accepts a directory of AsyncAPI documents or a single document; the path is resolved against the repository root, so a committed `docs/asyncapi` and an absolute cache written by something else both work. Each `operations[]` entry of an **AsyncAPI 3.x** document can contribute a `Destination` node keyed by broker and address, with `action: send` emitting `PUBLISHES_TO` and `action: receive` emitting `CONSUMES_FROM`, so a document and source code that name one address on one broker land on the same node. Edges start at the document, not at a callable — a document states that the service talks to an address, not which method does — and no address a document names is ever attached to an unresolved source site.
@ -526,25 +500,18 @@ gitnexus auto-sync reset # Clear failure state; leaves clones and in
```yaml ```yaml
sync_interval_minutes: 10 sync_interval_minutes: 10
analyze_timeout: 5m analyze_timeout: 5m
# Extra hosts beyond github.com, gitlab.com, and gitee.com. Exact names only.
# allowed_hosts: [gitlab.mycompany.com]
projects: projects:
- local_path: /absolute/path/to/clones - local_path: /absolute/path/to/clones
branches: [main, master] branches: [main, master]
# pdg: omit = preserve live index mode; true = keep PDG current;
# false = init default (warns, then strips PDG on the next successful rebuild).
# Do not paste pdg: false onto an existing watch file unless you intend to drop PDG.
pdg: false
overwrite_local_changes: false overwrite_local_changes: false
remote_urls: remote_urls:
- git@github.com:owner/repo.git - git@github.com:owner/repo.git
``` ```
- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`. - `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`.
- Remote URLs may use SSH SCP or HTTPS. Hosts are github.com, gitlab.com, and gitee.com unless listed in top-level `allowed_hosts` (exact DNS names, no wildcards). The CLI image includes OpenSSH; mount keys yourself. Invalid `watch_config.yml` skips auto-sync immediately. Auto-sync honors `.gitnexusrc` embeddings (HTTP embeddings env still required in the image). - Remote URLs must use SSH SCP form and are limited to GitHub, GitLab, or Gitee.
- `branches` are tried in order. The legacy `branch` field is supported, but do not set both. - `branches` are tried in order. The legacy `branch` field is supported, but do not set both.
- Set per-project `pdg: true` to keep the full control-flow, control/data-dependence, and taint layers current. Untouched configs that omit `pdg` preserve an existing index's mode and cannot silently strip PDG data. Do not paste `pdg: false` from this example onto an existing watch file unless you intend to drop PDG; an explicit `false` opt-out logs a warning before removing existing PDG data. Auto-sync requests atomic incremental publication where supported, so readers keep using the previous graph until a successful update is ready and a failed staged analysis leaves it intact; unsupported paths retain the analyzer's existing in-place behavior. - Analysis runs in an isolated worker; `analyze_timeout` defaults to, and cannot exceed, half of `sync_interval_minutes`. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths.
- Analysis runs in an isolated worker; `analyze_timeout` defaults to half of `sync_interval_minutes`, but may be longer (for example, a `30m` analysis timeout with `5` minute polling) up to Node's timer limit. If a polling tick arrives while analysis is active, it is coalesced into one immediate follow-up run using the newest commit. If the parent times out and leaves that worker running, the follow-up is deferred to the next interval so a leftover lock holder is not counted as a hard analyze failure. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths.
- Add `group_name` only after creating that group with `gitnexus group create <name>`. Partial clone output is isolated and removed after 14 days. - Add `group_name` only after creating that group with `gitnexus group create <name>`. Partial clone output is isolated and removed after 14 days.
See the [full auto-sync configuration and runtime reference](gitnexus/README.md#gitnexus-auto-sync) for concurrency, timeouts, failure thresholds, and runtime files. See the [full auto-sync configuration and runtime reference](gitnexus/README.md#gitnexus-auto-sync) for concurrency, timeouts, failure thresholds, and runtime files.
@ -599,25 +566,21 @@ Notes:
- The default branch is resolved as: `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`. - The default branch is resolved as: `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`.
- `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection. - `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection.
- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `maxProcesses`, `maxProcessBranching`, `maxProcessTraceDepth`, `maxEntryPointCandidates`, `springActuator`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`. - Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `springActuator`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`.
- The file is JSON only. Unknown keys and wrong JSON types fail fast with an actionable error before analysis starts. Process-detection knobs (`maxProcesses`, `maxProcessBranching`, `maxProcessTraceDepth`, `maxEntryPointCandidates`) that are not a positive integer warn and fall through to env, then the built-in default. - The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts.
</details> </details>
<details> <details>
<summary><strong>Environment variables</strong></summary> <summary><strong>Environment variables</strong></summary>
Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults. Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults.
| Variable | Default | Effect | Tune when… | | Variable | Default | Effect | Tune when… |
| ----------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITNEXUS_TEST_REPORT` | unset | Write a JSON execution receipt from `scripts/run-cross-platform.ts`; accepts a basename ending in `.json`. | CI platform shards use distinct filenames so the completeness check can require every report. |
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. | | `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. | | `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. | | `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
| `GITNEXUS_EMBEDDING_RETRY_TIMEOUTS` | unset | When truthy (`1`/`true`/`yes`), per-attempt HTTP embedding timeouts (`TimeoutError` on fetch or body read) go through the bounded `GITNEXUS_EMBEDDING_MAX_ATTEMPTS` retry loop instead of failing the job. Any other value leaves it off, so cloud/default timeouts remain terminal. | Local accelerators that drop a device lock when the client disconnects and succeed on the next request (observed with FastFlowLM on Ryzen AI). |
| `GITNEXUS_EMBEDDING_SIDECAR_TIMEOUT_MS` | `180000` (3 minutes) | Per-request IPC timeout for local embedding sidecar embed batches. On overrun the parent SIGKILLs the sidecar child and rejects the batch. Init still uses the HF download budget (`HF_DOWNLOAD_TIMEOUT_MS` × attempts), not this knob. | Large embed batches or slow local ONNX inference cause sidecar request timeouts during `analyze --embeddings`, `embeddings sync`, serve, or MCP. |
| `GITNEXUS_VECTOR_MAX_DISTANCE` | `0.6` for CLI/MCP `query`; `0.5` for standalone semantic search | Maximum cosine distance for vector hits, applied to both indexed search and exact-scan fallback. Hits must have `distance < cutoff`. Unset/blank uses the default; non-numeric, non-finite, zero, or negative values warn and use the default; finite values above `2` warn and clamp to `2`. | Paraphrased queries have weak semantic recall with your embedding model. See [Vector cutoff tuning](#vector-cutoff-tuning). |
| `GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS` | unset | When truthy (`1`/`true`/`yes`), forces in-process cache-guard validation once a batch has ≥128 requests. In-process mode also auto-selects when `packageRoot`/`buildRoot` fail `W_OK` with `EACCES`/`EROFS`. Otherwise those large batches use a Node subprocess probe. Batches under 128 always stay in-process. | Trusted or read-only installs where two identity subprocess spawns per analyze dominate wall time; leave unset to keep the default isolation path on writable trees. | | `GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS` | unset | When truthy (`1`/`true`/`yes`), forces in-process cache-guard validation once a batch has ≥128 requests. In-process mode also auto-selects when `packageRoot`/`buildRoot` fail `W_OK` with `EACCES`/`EROFS`. Otherwise those large batches use a Node subprocess probe. Batches under 128 always stay in-process. | Trusted or read-only installs where two identity subprocess spawns per analyze dominate wall time; leave unset to keep the default isolation path on writable trees. |
| `GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO` | on (unset) | Memoizes `resolveDefGraphId` per `nodeLookup` instance (WeakMap). Enabled by default. Set to `0`/`false`/`off`/`no` to disable and recompute on every call (debug / bisect memo bugs). | Suspecting stale graph-id resolution after a lookup rebuild, or comparing memo vs uncached cost on a large index. | | `GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO` | on (unset) | Memoizes `resolveDefGraphId` per `nodeLookup` instance (WeakMap). Enabled by default. Set to `0`/`false`/`off`/`no` to disable and recompute on every call (debug / bisect memo bugs). | Suspecting stale graph-id resolution after a lookup rebuild, or comparing memo vs uncached cost on a large index. |
| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. | | `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. |
@ -626,18 +589,9 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. | | `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. | | `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. |
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. | | `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
| `GITNEXUS_MAX_PROCESSES` | dynamic (`max(20, round(symbols/10))`) | Analyze-time process-detection process cap. Equivalent to `--max-processes <n>` / `.gitnexusrc` `maxProcesses`. Explicit values replace the dynamic formula (not a multiplier). `0` is invalid, not unlimited. Changing this re-detects flows on the next analyze without `--force`. Distinct from query-time `IMPACT_MAX_CHUNKS`. | `[processes] … whole flows are MISSING` names `--max-processes` after entry points were never traced or flows were dropped. Tracing does not start the next entry once collected traces already reach `maxProcesses * 2`; a started entry can still emit every trace that entry produces. |
| `GITNEXUS_MAX_PROCESS_BRANCHING` | `4` | Analyze-time per-node branching cap during flow tracing. Equivalent to `--max-process-branching <n>`. Shape-only: raising it shortens fewer traces; it does not restore whole missing flows. | A flow is present but `calleesDropped` is high at debug. |
| `GITNEXUS_MAX_PROCESS_TRACE_DEPTH` | `10` | Analyze-time DFS depth cap during flow tracing. Equivalent to `--max-process-trace-depth <n>`. Shape-only. | A reported flow is shorter than the code path (`tracesDepthCapped` at debug). |
| `GITNEXUS_MAX_ENTRY_POINT_CANDIDATES` | `200` | Ranked entry-point candidate pool. Equivalent to `--max-entry-point-candidates <n>`. Raising `--max-processes` alone does not clear `entryPointCandidatesDropped`. Doubling the current cap is the usual first raise; setting it to the full remaining candidate count can exhaust CPU and memory. | The `[processes]` warning reports candidate entry points that never ranked in. |
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. | | `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". | | `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". |
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. | | `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
| `GITNEXUS_STORAGE_PATH` | unset (`<repo>/.gitnexus/`) | Complete external index directory. This preserves the existing configuration semantics and takes precedence over `GITNEXUS_STORAGE_ROOT` when both are set. | You already keep one repository index outside its checkout or need one explicit index location. |
| `GITNEXUS_STORAGE_ROOT` | unset | Absolute root directory for external indexes. GitNexus creates an isolated `<repo-basename>-<canonical-path-hash>/` slot beneath it for each repository, then registers the resolved slot so `status`, MCP, and `serve` can reopen it later. | You want to manage multiple repository indexes centrally or keep generated data outside source checkouts. |
| `GITNEXUS_SHARED_STORE` | unset (on) | Set to `off` (or `0`, `false`, `no`) to turn off shared index stores for both linked git worktrees and sibling clones; every checkout then indexes into its own `.gitnexus/`. Sharing is also off whenever `GITNEXUS_STORAGE_PATH` or `GITNEXUS_STORAGE_ROOT` is set. | Disk or memory is not a concern, or you want each worktree's index fully independent. |
| `GITNEXUS_CONTENT_RETENTION` | `full` | Source-text retention profile: `full` keeps file and symbol text, `symbol` keeps symbol snippets without full file content, and `none` keeps the structural graph without source body text. | You need to reduce persisted source text while preserving graph structure. |
| `GITNEXUS_SKIP_FTS` | unset | When exactly `1`, skips FTS extension loading and keyword index creation during analyze. Equivalent to `--skip-fts`; a later analyze without either option restores FTS. | Graph-only consumers with their own retrieval, or short-lived indexes that do not need keyword search. |
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. | | `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. | | `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. | | `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. |
@ -657,22 +611,6 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. | | `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. |
| `GITNEXUS_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` value — which upstream hops may set `X-Forwarded-*`, and so what the per-IP rate limiter reads as the client IP. Set it to the exact number of proxies you control. Every hop past that is one more entry of the chain the caller gets to write. `false`/`no`/`off` (and a `0` hop count) trust no hop; a proxy list Express can compile (`loopback`, `10.0.0.0/8, 127.0.0.1`) names them instead. `true`/`yes`/`on` is **rejected**: it reads the client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a fresh rate-limit key per request, and express-rate-limit rejects it too (`ERR_ERL_PERMISSIVE_TRUST_PROXY`). Counts above `16` are rejected as well, as a sanity ceiling rather than a safety boundary. Any invalid value warns and falls back to the default. Bind non-loopback with this unset and `serve` warns: a load balancer outside the private ranges is untrusted, so every request keys to the balancer and the per-IP limit becomes one shared limit. | `serve` sits behind a load balancer outside the private ranges (AWS ALB, Cloudflare, CGNAT), where every request otherwise collapses to the proxy hop and rate limiting goes global. | | `GITNEXUS_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` value — which upstream hops may set `X-Forwarded-*`, and so what the per-IP rate limiter reads as the client IP. Set it to the exact number of proxies you control. Every hop past that is one more entry of the chain the caller gets to write. `false`/`no`/`off` (and a `0` hop count) trust no hop; a proxy list Express can compile (`loopback`, `10.0.0.0/8, 127.0.0.1`) names them instead. `true`/`yes`/`on` is **rejected**: it reads the client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a fresh rate-limit key per request, and express-rate-limit rejects it too (`ERR_ERL_PERMISSIVE_TRUST_PROXY`). Counts above `16` are rejected as well, as a sanity ceiling rather than a safety boundary. Any invalid value warns and falls back to the default. Bind non-loopback with this unset and `serve` warns: a load balancer outside the private ranges is untrusted, so every request keys to the balancer and the per-IP limit becomes one shared limit. | `serve` sits behind a load balancer outside the private ranges (AWS ALB, Cloudflare, CGNAT), where every request otherwise collapses to the proxy hop and rate limiting goes global. |
### Vector cutoff tuning
`GITNEXUS_VECTOR_MAX_DISTANCE` controls which vector candidates enter hybrid search. Cosine distance is lower for more similar vectors; the appropriate cutoff depends on the embedding model and repository. A higher cutoff can recover useful semantic matches, but can also admit less relevant hits and change the fused ranking. The default is `0.6` for CLI and MCP `query`, and `0.5` for the standalone semantic-search function.
For an index that already has embeddings, compare the default with `0.8` and `1.0` on representative queries, including paraphrases, exact symbol names, and queries that should have no relevant result:
```bash
GITNEXUS_VECTOR_MAX_DISTANCE=0.8 gitnexus query "how are expired sessions removed" --limit 10
```
Check both whether the expected symbols appear and where they rank. In [#3457](https://github.com/abhigyanpatwari/GitNexus/issues/3457), the reporter's `voyage-code-4` sample found 20 of 32 paraphrased targets at `0.6`, 25 at `0.8`, and 28 at `1.0`. Those values are a tuning starting point for that model, not universal recommendations: one paraphrased target fell from first to twelfth at `1.0`. The sample covered eight repositories, recorded no raw distances, used agent-written queries after reading the code, and did not send Voyage's `input_type`.
Set the variable in the process that runs the search. For MCP, add it to the server's launch environment and restart the server; for `gitnexus serve`, set it in that process's environment and restart it. Setting it only during `analyze`, or exporting it in another shell, does not configure an existing server. Changing the cutoff requires no reindex.
The cutoff only filters available vector candidates. It cannot add missing embeddings or repair a mismatch between the indexing and query-time embedding configuration; see the [embeddings runbook](RUNBOOK.md#embeddings). A cutoff of `2` is very permissive, but still rejects distance exactly `2` and retains the search candidate limits.
</details> </details>
<details> <details>
@ -713,13 +651,12 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
| Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | | Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | | PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ |
| Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | | Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ | | C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | | C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Objective-C | ✓ | — | ✓ | ✓ | ✓ | — | — | — | — |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | | Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ | | Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ |
@ -731,9 +668,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
GitNexus uses a **global registry** so one MCP server can serve multiple indexed repos. No per-project MCP config needed — set it up once and it works everywhere. GitNexus uses a **global registry** so one MCP server can serve multiple indexed repos. No per-project MCP config needed — set it up once and it works everywhere.
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo by default (portable, gitignored). `GITNEXUS_STORAGE_PATH` selects one complete external index directory and preserves the established configuration behavior. To manage multiple repositories under one external directory, set `GITNEXUS_STORAGE_ROOT`; GitNexus derives an isolated `<repo-basename>-<canonical-path-hash>/` slot beneath it for each repository. If both variables are set, `GITNEXUS_STORAGE_PATH` takes precedence. GitNexus registers the resolved slot in `~/.gitnexus/registry.json`, allowing later `status`, MCP, and `serve` commands to reopen the index without repeating the environment variable. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). Read-only tools can omit `repo` when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Outside those paths—and for mutating tools with multiple indexed repos and no MCP default—pass `repo` explicitly. Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). Read-only tools can omit `repo` when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Outside those paths—and for mutating tools with multiple indexed repos and no MCP default—pass `repo` explicitly.
**Worktrees share one index store.** When a repository has linked worktrees (`git worktree add`), the main checkout and every worktree index into one store at `~/.gitnexus/stores/<repo>/` instead of each keeping a full `.gitnexus/`. Checkouts at the same commit with no local changes read one shared, read-only graph: one copy on disk and one open database in MCP. A checkout with uncommitted changes gets its own graph, copied from the nearest shared graph and updated incrementally rather than rebuilt. Parse caches are shared too. Each worktree keeps a small `.gitnexus/store.json` pointer, and an index it had before sharing is left in place; `gitnexus status` reports it and `gitnexus clean --local-index --force` removes it. `gitnexus clean` in one worktree removes only that worktree's slot and any shared graph no other checkout uses; `gitnexus clean --gc` also drops slots whose worktree was deleted. Independent clones of one repository share too: when another registered clone has the same `origin` URL, `gitnexus analyze` in a clone joins that clone's store (or starts one the other clone joins on its next analyze). A lone clone keeps its own `.gitnexus/`. `gitnexus analyze --share-with <name-or-path>` joins a specific checkout's store after checking the `origin` URLs match, and `--no-share` moves a clone back to its own `.gitnexus/` and keeps it out until `--share-with`. On filesystems with copy-on-write clones (APFS, btrfs, XFS) a checkout's private graph shares its unchanged pages with the shared graph on disk; elsewhere it is a full copy, and `gitnexus status` says which. Queries cannot combine two graphs, because LadybugDB reads one database per query, so a checkout with edits always has a complete graph of its own. Set `GITNEXUS_SHARED_STORE=off` (or `0`, `false`, `no`) to turn sharing off for worktrees and clones alike.
<details> <details>
<summary><strong>Architecture diagram</strong></summary> <summary><strong>Architecture diagram</strong></summary>
@ -950,10 +885,9 @@ The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAs
```bash ```bash
git clone https://github.com/abhigyanpatwari/gitnexus.git git clone https://github.com/abhigyanpatwari/gitnexus.git
cd gitnexus/gitnexus-web && npm install cd gitnexus/gitnexus-shared && npm install && npm run build
# Compile sibling gitnexus-shared with this package's TypeScript 7 (do not npm ci shared). cd ../gitnexus-web && npm install
cd ../gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js npm run dev
cd ../gitnexus-web && npm run dev
# Then in another terminal, start the backend the frontend connects to: # Then in another terminal, start the backend the frontend connects to:
npx gitnexus@latest serve npx gitnexus@latest serve
``` ```
@ -1013,7 +947,7 @@ docker compose --env-file .env up -d
Files: Files:
- [Dockerfile.web](Dockerfile.web) — builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend. - [Dockerfile.web](Dockerfile.web) — builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend.
- [Dockerfile.cli](Dockerfile.cli) — builds the CLI/server (with its native deps) and runs `gitnexus serve --host 0.0.0.0`. Local embeddings are **not** in the image (`onnxruntime-node` is opt-in; runtime npm is stripped). Bind-mount a prefix or set `GITNEXUS_EMBEDDING_URL`. - [Dockerfile.cli](Dockerfile.cli) — builds the CLI/server (with its native deps) and runs `gitnexus serve --host 0.0.0.0`.
- [docker-compose.yaml](docker-compose.yaml) — starts both signed images side by side. - [docker-compose.yaml](docker-compose.yaml) — starts both signed images side by side.
- [.env.example](.env.example) — overrides for image names, container names, ports, and the workspace mount. - [.env.example](.env.example) — overrides for image names, container names, ports, and the workspace mount.
@ -1153,7 +1087,7 @@ Built by the community — not officially maintained, but worth checking out.
## Security & Privacy ## Security & Privacy
- **CLI**: everything runs locally on your machine. No network calls. Indexes are stored in `.gitnexus/` by default (gitignored), in the complete external directory selected by `GITNEXUS_STORAGE_PATH`, or in repository-specific slots beneath `GITNEXUS_STORAGE_ROOT`. Global registry at `~/.gitnexus/` stores only paths and metadata. - **CLI**: everything runs locally on your machine. No network calls. Index stored in `.gitnexus/` (gitignored). Global registry at `~/.gitnexus/` stores only paths and metadata.
- **Web**: everything runs in your browser. No code uploaded to any server. API keys stored in localStorage only. - **Web**: everything runs in your browser. No code uploaded to any server. API keys stored in localStorage only.
- Open source — audit the code yourself. - Open source — audit the code yourself.

View file

@ -87,14 +87,6 @@ npx gitnexus analyze --force
If it recurs, the cause is almost always environmental rather than a code defect: check free disk space on the volume holding `.gitnexus/`, make sure no second `analyze` is running against the same repo (both use `.gitnexus/csv` for staging), then run `npx gitnexus doctor`. The check compares in-memory relationship totals (including streamed rows) against what the DB hands back, and is deliberately skipped on incremental runs, where the two counts are not comparable. If it recurs, the cause is almost always environmental rather than a code defect: check free disk space on the volume holding `.gitnexus/`, make sure no second `analyze` is running against the same repo (both use `.gitnexus/csv` for staging), then run `npx gitnexus doctor`. The check compares in-memory relationship totals (including streamed rows) against what the DB hands back, and is deliberately skipped on incremental runs, where the two counts are not comparable.
**Weak semantic recall despite existing embeddings:** If paraphrased queries look like keyword-only search, the distance cutoff may be too strict for the embedding model. CLI and MCP `query` default to `0.6`. From the indexed repository, try a broader cutoff:
```bash
GITNEXUS_VECTOR_MAX_DISTANCE=0.8 npx gitnexus query "how are expired sessions removed" --limit 10
```
Compare target inclusion and ranking against the default; a broader cutoff also admits less relevant hits. Set the variable in the MCP/serve launch environment and restart that process when tuning a server. A cutoff change needs no reindex and setting it only during `analyze` does not persist it. If the index has no vectors, generate embeddings first; if the embedding model or dimensions differ between indexing and querying, align that configuration and regenerate vectors. See [Vector cutoff tuning](README.md#vector-cutoff-tuning) for validation rules and the limited `voyage-code-4` evidence from #3457.
**Large repos:** Analyze may skip or limit embedding work when node counts are very high; watch CLI output. **Large repos:** Analyze may skip or limit embedding work when node counts are very high; watch CLI output.
--- ---
@ -195,58 +187,6 @@ If the error text is `"Only one write transaction at a time is allowed in the sy
--- ---
## File acquisition/reclaim guard recovery
The portable file-lock backend uses `analyze.lock.guard` beside `analyze.lock`.
Every acquisition, including an empty slot, exclusively creates the guard before
inspecting, reclaiming, creating, and verifying the main lock. It removes the
guard before returning a workload handle or waiting on a live workload holder.
Linux abstract-socket and Windows named-pipe locking are unchanged.
A stalled or crashed guard owner blocks file acquisition even when its PID is
dead, its metadata is incomplete, or no main lock exists. **The guard is never
automatically stolen.** Guard contention times out after at most 30 seconds,
capped by the remaining acquisition timeout. This separate ceiling applies even
when `GITNEXUS_INDEX_LOCK_TIMEOUT_MS` is zero or negative (unbounded workload wait).
A guard-cleanup failure rejects acquisition; it must not start unprotected work.
Manual recovery is an outage procedure, not an age/PID-based cleanup:
1. Identify the exact lock directory named in the error. This shared primitive
also protects group sync and registry operations, not just repo analysis.
2. Stop **all relevant writers** and prevent restart: editor/agent hooks, watch
processes, scheduled jobs, services, and any containers sharing the directory.
Account for paused processes and every host with access. If quiescence cannot
be established, do not remove the guard. PID metadata is diagnostic only.
3. While restart remains disabled, inspect and preserve the guard/main records
for diagnosis, then remove only that directory's orphan `analyze.lock.guard`
and, if present, its orphan `analyze.lock`. Do not remove databases or sidecars
as part of lock recovery. Do not use a recursive or wildcard cleanup.
4. Ensure all participating writers use the guarded version and the same locking
backend/domain, then restart in a controlled fashion.
**Upgrade requires a coordinated stop/upgrade/restart.** Concurrent older
versions ignore the guard and can still displace live locks; mixed-version
mutual exclusion is not guaranteed. The file protocol assumes reliable atomic
local-filesystem `O_EXCL` creation and cooperating processes. Network/distributed
filesystems, external file replacement, and uncoordinated manual deletion are not
covered. A process crash while holding the short-lived guard trades automatic
recovery for fail-closed safety. Denied file creation returns a non-owning
`lockFree` handle only when neither workload lock nor acquisition guard exists;
unreadable paths fail closed. No staging sweep runs without ownership. Analysis,
registry transactions, group synchronization, and embeddings sync refuse
`lockFree` handles, including an otherwise up-to-date analysis on a file-backend
read-only mount.
The socket backend can still acquire ownership on a read-only index mount.
Heterogeneous permissions are not proof that another process cannot write.
If guard cleanup fails after this attempt created its workload record, acquisition
is refused and token-exact workload cleanup is attempted before returning the
error. Failed or unverifiable cleanup must be diagnosed under the same quiesced
recovery procedure above; never delete a possibly active successor's record.
---
## Where to dig deeper ## Where to dig deeper
- Architecture overview: [ARCHITECTURE.md](ARCHITECTURE.md) - Architecture overview: [ARCHITECTURE.md](ARCHITECTURE.md)

View file

@ -19,7 +19,6 @@ From `gitnexus/`:
| Command | What it runs | When to use | | Command | What it runs | When to use |
| ----------------------------- | -------------------------------------------------- | ------------------------------------- | | ----------------------------- | -------------------------------------------------- | ------------------------------------- |
| `npm test` | Full suite (all 3 vitest projects) | Before opening a PR | | `npm test` | Full suite (all 3 vitest projects) | Before opening a PR |
| `npm run typecheck:tests` | TypeScript checks for source, tests, and helpers | Before opening a PR |
| `npm run test:unit` | Unit tests only (`test/unit/`) | Tight development loop | | `npm run test:unit` | Unit tests only (`test/unit/`) | Tight development loop |
| `npm run test:integration` | Integration tests (`test/integration/`) | After changing pipelines, DB, workers | | `npm run test:integration` | Integration tests (`test/integration/`) | After changing pipelines, DB, workers |
| `npm run test:coverage` | Full suite + v8 coverage with thresholds | Checking coverage impact | | `npm run test:coverage` | Full suite + v8 coverage with thresholds | Checking coverage impact |
@ -27,11 +26,6 @@ From `gitnexus/`:
| `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue | | `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue |
| `npm run test:watch` | Vitest in watch mode | Active development | | `npm run test:watch` | Vitest in watch mode | Active development |
Vitest transpiles TypeScript without checking types. Run `npm run typecheck:tests`
in `gitnexus/` to check test code and helpers against the production types. This
uses `tsc --noEmit -p tsconfig.test.json`; parser input files under `test/fixtures/`
are excluded because they are sample source code, not part of the test program.
### `gitnexus-web/` commands ### `gitnexus-web/` commands
From `gitnexus-web/`: From `gitnexus-web/`:
@ -45,9 +39,7 @@ From `gitnexus-web/`:
### Before opening a PR ### Before opening a PR
```bash ```bash
# gitnexus-shared/dist must exist first. `npm install` / `npm run build` in cd gitnexus && npx tsc --noEmit && npm test
# gitnexus/ compiles it via parent `lib/tsc.js` (do not npm ci gitnexus-shared).
cd gitnexus && npx tsc --noEmit && npm run typecheck:tests && npm test
cd ../gitnexus-web && npx tsc -b --noEmit && npm test cd ../gitnexus-web && npx tsc -b --noEmit && npm test
``` ```
@ -125,45 +117,7 @@ GitHub Actions (`.github/workflows/ci.yml`) orchestrate:
| `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages | | `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages |
| `ci-e2e.yml` | e2e (chromium) | Playwright E2E, gated on `gitnexus-web/**` changes | | `ci-e2e.yml` | e2e (chromium) | Playwright E2E, gated on `gitnexus-web/**` changes |
The `CI Gate` job in `ci.yml` requires the quality and test workflows to pass. The `CI Gate` job in `ci.yml` is the single required check for branch protection. It requires quality, tests, e2e, and scope-parity to all pass.
The browser E2E workflow must pass or be skipped because no web files changed.
Branch protection also requires six platform check names from the former
three-shard matrix. These names remain as aggregate gates: all native shards
and the `every test executed` audit must succeed before any of them passes.
Failed, cancelled, skipped, or missing dependency results fail these gates.
The actual native tests run in the current Windows/macOS shard matrix.
The `typecheck` job runs both the production compiler check and
`npm run typecheck:tests`. A type error in either check fails the job and the CI gate.
### Complete execution, including platform and benchmark tests
The required `every test executed` job reconciles execution receipts from Ubuntu
coverage, every Windows/macOS shard, the serial benchmark run, and the real Python
workflow preflight. It requires a recorded pass for every collected test. A skip
on Linux is satisfied only by a pass of that exact test in another required job.
Test identities include the file, suite/title and source location; ambiguous
parameterized cases must have unique titles. Missing receipts, missing test files,
unhandled runner errors, failed hooks, failed assertions, and tests with no pass
all fail the gate. Web tests are checked separately with the same rules.
The locked pytest suite and Linux/Windows containment jobs also upload JUnit
receipts. The same gate checks every Python test file was collected and every
case passed in at least one job, while preserving failures from any job. The
Linux containment job supplies Bubblewrap, the pinned CLI, and built Vitest
dependencies for tests that cannot run in the basic Python job. Python results
are included in the combined PR report.
The PR report shows the reconciled result as **Unverified**. Zero means every test
has execution evidence; individual OS logs still show tests that require another
OS as skipped. Failed executions remain failures even if another job passes.
`npm run test:benchmarks` discovers all tests gated by `GITNEXUS_BENCH` and runs
them serially. Keep timing measurements out of parallel coverage workers. The
`eval-tests` job installs the locked Python dependencies and runs the workflow
preflight Vitest tests as well as pytest; those tests must exercise real Python
validation, never a stubbed success.
## Regression testing ## Regression testing

View file

@ -331,8 +331,8 @@ const respondOk = (_req, res) => {
// //
// upstream request handler, replaceable mid-test via `ctx.handler`; // upstream request handler, replaceable mid-test via `ctx.handler`;
// null points the proxy at a port nothing ever listens on // null points the proxy at a port nothing ever listens on
// listenAfterMs bind the upstream this late after the first refused attempt // listenAfterMs bind the upstream this late, so the first attempt(s) hit
// (a single-instance restart window) // ECONNREFUSED (a single-instance restart window)
// schemeless drop http:// from GITNEXUS_UPSTREAM_URL, the way Render's // schemeless drop http:// from GITNEXUS_UPSTREAM_URL, the way Render's
// `fromService: { property: hostport }` yields it // `fromService: { property: hostport }` yields it
// env extra environment for docker-server.mjs // env extra environment for docker-server.mjs
@ -366,15 +366,18 @@ async function withProxy(
}) })
: null; : null;
// Keep the upstream port bound until the proxy port is chosen. Releasing it // A late (or never) bind needs its port reserved up front; otherwise let the
// sooner lets the OS assign both services the same port and proxy to itself. // OS assign one at listen time.
const reservation = server ?? createServer(); const upstreamPort =
const upstreamPort = await new Promise((r) => server && listenAfterMs === 0
reservation.listen(0, '127.0.0.1', () => r(reservation.address().port)), ? await new Promise((r) => server.listen(0, '127.0.0.1', () => r(server.address().port)))
); : await getFreePort();
const port = await getFreePort(); const bindTimer =
let bindTimer = null; server && listenAfterMs > 0
? setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs)
: null;
const port = await getFreePort();
const target = `127.0.0.1:${upstreamPort}`; const target = `127.0.0.1:${upstreamPort}`;
const proc = spawnServerWithEnv(dir, port, { const proc = spawnServerWithEnv(dir, port, {
GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`, GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`,
@ -387,25 +390,18 @@ async function withProxy(
...env, ...env,
}); });
proc.stderr.setEncoding('utf8'); proc.stderr.setEncoding('utf8');
const collectStderr = (chunk) => { proc.stderr.on('data', (chunk) => {
ctx.stderr += chunk; ctx.stderr += chunk;
// Process startup must not consume the restart window or skip the retry. });
if (server && listenAfterMs > 0 && !bindTimer && ctx.stderr.includes('ECONNREFUSED; retry')) {
bindTimer = setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs);
}
};
proc.stderr.on('data', collectStderr);
try { try {
await waitForServer(port); await waitForServer(port);
if (!server || listenAfterMs > 0) await new Promise((r) => reservation.close(r));
await fn(port, ctx); await fn(port, ctx);
} finally { } finally {
proc.stderr.off('data', collectStderr);
if (bindTimer) clearTimeout(bindTimer); if (bindTimer) clearTimeout(bindTimer);
await killAndWait(proc); await killAndWait(proc);
if (reservation.listening) { if (server?.listening) {
reservation.closeAllConnections?.(); server.closeAllConnections?.();
await new Promise((r) => reservation.close(r)); await new Promise((r) => server.close(r));
} }
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true });
} }
@ -665,8 +661,8 @@ it('returns 502 when the upstream is unreachable', async () => {
// -- Connection-retry across an upstream restart window --------------------- // -- Connection-retry across an upstream restart window ---------------------
// //
// `listenAfterMs: 400` binds the upstream 400ms after the first ECONNREFUSED, // `listenAfterMs: 400` binds the upstream late, so the first attempt hits
// so the request must be retried — a single-instance restart. The default 3 // ECONNREFUSED and must be retried — a single-instance restart. The default 3
// attempts (backoff 250ms, 500ms) span ~750ms, so a retry lands after the bind. // attempts (backoff 250ms, 500ms) span ~750ms, so a retry lands after the bind.
it('retries a connection-refused POST and succeeds once the upstream is up', async () => { it('retries a connection-refused POST and succeeds once the upstream is up', async () => {
@ -680,7 +676,6 @@ it('retries a connection-refused POST and succeeds once the upstream is up', asy
assert.match(res.body, /"ok":true/); assert.match(res.body, /"ok":true/);
assert.equal(ctx.calls, 1, 'upstream must run the job exactly once (no double-execute)'); assert.equal(ctx.calls, 1, 'upstream must run the job exactly once (no double-execute)');
assert.equal(ctx.body, '{"repo":"x"}', 'buffered body replayed intact'); assert.equal(ctx.body, '{"repo":"x"}', 'buffered body replayed intact');
assert.match(ctx.stderr, /ECONNREFUSED; retry/, 'the restart gap must exercise a retry');
}); });
}); });

View file

@ -1,44 +0,0 @@
# Jupyter notebook (.ipynb) indexing
Status: implemented (Python code cells)
GitNexus indexes Jupyter notebooks by extracting Python code cells and parsing them with the existing Python language provider. Notebooks are not executed.
## Goal
After `analyze`, functions, classes, and imports defined in Python code cells are queryable like ordinary `.py` files.
## Compatibility
- `.ipynb` is detected as Python (`gitnexus-shared` `EXTENSION_MAP` and `pythonProvider.extensions`).
- Extraction lives in `gitnexus/src/core/ingestion/ipynb-extractor.ts`. Shared ingestion modules do not name nbformat AST types.
- Notebooks are **not** Python import targets. A notebook may import `.py` modules; `import some_notebook` does not resolve to an `.ipynb`.
- Files over the walker size cap (default 512KB, `GITNEXUS_MAX_FILE_SIZE`) are skipped like any other oversized file. Output-heavy notebooks may need a higher cap. Outputs are not stripped in this slice.
- Group-layer FastAPI/Flask/Django scanners that require a `.py` suffix still ignore notebooks.
## Kernel and magics
- Skip the file when `kernelspec.language` or `language_info.name` is present and is not a Python-family name (`python`, `python2`, `python3`, `ipython`, `python 3`, `ipython3`). `python` and `python3` together still index.
- If `kernelspec.language` is missing, `kernelspec.name` is used only when it is an obvious language id (`python3`, `ir`, `julia-1.8`). Conda env names are ignored.
- If `language_info.name` is missing, `language_info.file_extension` (`.py` vs `.r` / `.jl`) is used the same way.
- If those fields are absent, code cells are treated as Python unless a cell's own `language`, `metadata.language`, or `metadata.vscode.languageId` says otherwise.
- A cell whose first non-empty line is a foreign cell magic (`%%bash`, `%%html`, `%%sql`, `%%R`) is skipped. Python-body cell magics (`%%time`, `%%timeit`, `%%capture`, `%%prun`, `%%debug`, `%%px`, `%%python`) stay, with the magic line commented.
- `%run`, `%load`, and `%loadpy` of a local `.py` path become `import module` on that same line so the notebook links to the file. URLs, `..` paths, and `.ipynb` targets stay comments. The notebook is not executed.
- Sage, SageMath, MicroPython, Pyodide, PyPy, and PySpark kernels are indexed as Python. SQL, R, and Julia cells are not.
- A cell with an unclosed string or bracket is commented out so it cannot hide later cells. Those cells are concatenated in order; one syntax error no longer drops the rest of the notebook.
- Line magics (`%`), shell (`!`), and IPython help (`train?`, `?train`) are commented in place so JSON line mapping stays affine.
- nbformat v4 `cells` / `source` is the normal path. nbformat v3 `worksheets[].cells` and code-cell `input` are accepted. A leading UTF-8 BOM is accepted. Markdown and raw cells are not code.
## Line numbers
Graph `startLine` / `endLine` are 0-based coordinates in the on-disk `.ipynb` JSON. FTS/MCP symbol snippets reconstruct cell Python; they do not slice raw JSON.
Concatenating cells in document order is notebook semantics. An earlier cell with a syntax error may cause later definitions to be missed; that is a documented limitation, not a per-cell fallback parse.
## Tests
- `gitnexus/test/unit/ipynb-extractor.test.ts`
- `gitnexus/test/unit/ingestion-utils.test.ts` (`.ipynb` detection)
- `gitnexus/test/integration/resolvers/ipynb-python-pipeline.test.ts`
No Jupyter, nbconvert, or nbformat runtime dependency.

View file

@ -1,109 +0,0 @@
# Objective-C Language Provider
Status: implemented
The deterministic provider is covered by focused unit and integration tests. The parser-loader ABI
smoke runs in the published multi-OS test matrix, and the native prebuild workflow owns
Objective-C together with all six vendored grammar targets. This status describes the implemented
MVP; it does not promise full Objective-C runtime dispatch.
## Goal
Add deterministic, symbol-level Objective-C analysis to GitNexus. The first release must support high-confidence code navigation and direct static dependency analysis for `.m`, `.mm`, and Objective-C `.h` files. It must not imply that Objective-C runtime dispatch is fully resolved.
The provider belongs in the existing language-provider and scope-resolution extension points. Shared ingestion code must remain language-agnostic.
## Compatibility contract
- Existing language detection and parsing must remain unchanged.
- A `.h` file must be classified from its content or surrounding context; it cannot be unconditionally claimed by Objective-C because C and C++ also use that extension.
- If Objective-C grammar loading fails, the error must clearly name the missing provider/grammar and cannot corrupt a previously valid index.
- Provider and grammar versions must be stored in index metadata. A version change that can alter node identity or edges requires a full rebuild.
- No LLM participates in parsing, name resolution, or edge creation. Analysis is Tree-sitter plus deterministic static resolution.
## MVP model
The provider must extract and connect:
- Classes, superclasses, protocols, categories, class extensions, properties, ivars, C functions, imports, declarations, and implementations.
- Instance and class methods, preserving their complete multi-part selector.
- Inheritance, protocol conformance, import, declaration/implementation, host-class/category, and statically resolved call relationships.
Stable identity must include enough ownership to distinguish same-named methods. Recommended forms are:
```text
objc:class:<ClassName>
objc:protocol:<ProtocolName>
objc:category:<HostClass>:<CategoryName>
objc:method:<Owner>:-:<selector>
objc:method:<Owner>:+:<selector>
objc:function:<qualified-or-file-scoped-name>
```
For example, `-loadData:completion:` and `+loadData:completion:` are different symbols. A category method remains linked to both its category and host class; querying the host class must expose distributed implementations.
## Resolution policy
Resolution must be conservative. A missing or dynamic target is evidence of uncertainty, not proof that no target exists.
| Receiver case | Required result |
| --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Explicit class name, `self`, or `super` | Resolve when the owner is statically known. |
| Local, parameter, property, or ivar with known static type | Resolve to matching owner and selector. |
| Protocol-typed receiver | Link the protocol method and identify possible implementations as candidates. |
| Multiple host-class/category implementations of one selector | Record all static candidates as evidence; do not emit a certain call edge because runtime image-load order is unknown. |
| `id`, `Class`, macros, reflection, `performSelector:`, `NSInvocation`, runtime injection, or unknown type | Store selector/location with `resolution=unresolved`; do not emit a certain call edge. |
The provider should first collect file-local declarations, imports, and types, then resolve across the repository. It must use structured Tree-sitter captures or AST traversal, not regular expressions over source text. Multi-part selectors, block arguments, nullability annotations, generics, macros, and multiline declarations make a regex-only extractor unsafe.
## Imports and incremental correctness
- Resolve quoted project imports against the current directory, configured include roots, and indexed headers. Model framework imports as external-module evidence without downloading SDK source.
- Merge `@interface`, `@implementation`, categories, and extensions across files.
- A changed header, protocol, class declaration, or category invalidates importing and affected implementation/call-resolution state. Incremental output after such a change must match a full rebuild.
- Index metadata must record provider version, grammar version, include/exclude configuration, and parsing options used for resolution.
## Implementation sequence
1. Add and package a pinned Objective-C Tree-sitter grammar; verify macOS arm64 and the production Linux runner can load it.
2. Add language detection for `.m`, `.mm`, and content-classified `.h` files.
3. Implement AST extraction and stable IDs for declarations and definitions.
4. Implement repository-level merge, imports, inheritance, protocol, and category relationships.
5. Add conservative message-send resolution and explicit unresolved evidence.
6. Integrate invalidation, metadata comparison, MCP/CLI output, and fixtures.
## Fixtures and acceptance
Create a minimal Objective-C fixture containing a class, protocol, category, extension, superclass, properties, ivars, C function, imports, multi-part selector, block parameter, `self`, `super`, protocol receiver, and `id` receiver. Use `symodulebridge` as a real integration fixture after the minimal suite is stable.
The acceptance bar is:
- `query "SYModuleCaller"` yields class/method semantic nodes, not only file nodes.
- `context "SYModuleCaller" --file <path>` yields declaration, implementation, imports, and known references.
- Known statically typed message sends create call edges; dynamic sends are marked unresolved.
- Same selector on multiple classes, a category override, and `+` versus `-` methods remain distinct.
- A `.m`, `.h`, protocol, or category edit produces results equivalent to a clean rebuild.
- Generated documentation, dependency directories, and build output are excluded through explicit indexing configuration.
## Non-goals
The MVP does not promise exact runtime type inference for `id` or `instancetype`, reflection, swizzling, arbitrary category replacement, dynamic selector construction, or complete impact analysis across every runtime dispatch path. Tool results must surface confidence and unresolved evidence rather than presenting guesses as certain graph facts.
## Current implementation coverage
Implemented capabilities:
- Vendored `tree-sitter-objc` grammar, registered through the existing Tree-sitter loader.
- `.m` and `.mm` language mapping plus content-based `.h` classification so plain C/C++ headers are not unconditionally claimed.
- LanguageProvider extraction for classes, protocols, categories, extensions, methods, properties, ivars, C functions, imports, unresolved message evidence, stable Objective-C qualified names, and provider/grammar metadata.
- Length-preserving preprocessing of bare, file-scope all-caps macro markers before Tree-sitter parsing. This recovers declarations after wrappers such as `RCT_EXTERN_C_BEGIN` / `RCT_EXTERN_C_END` without expanding macros or adding framework-specific rules.
- ScopeResolver edges for imports, inheritance, protocol conformance, category host membership, implementation evidence, and conservative static message sends.
- Persisted query/context support for Objective-C class and method nodes, including implementation evidence via `DECLARES`.
- Regression tests for grammar loading, `.h` classification, stable identities, conservative calls, metadata feature mismatch, persisted query/context behavior, and incremental-vs-force parity for Objective-C fixture edits.
Known limits of this MVP:
- The first version does not perform full Objective-C runtime dispatch, swizzling, dynamic selector construction, macro expansion, or `id` flow inference. Bare file-scope marker macros are elided only to preserve parser recovery; their expansion semantics are not interpreted.
- Protocol receiver handling records the protocol method and candidate implementation evidence, but candidate implementations are not emitted as certain call edges.
- When a host class and one or more named categories define the same selector, the provider records candidate evidence rather than choosing a runtime winner or emitting multiple certain call edges.
- Objective-C++ `.mm` files are parsed with the Objective-C grammar path for this MVP; deep C++ semantic extraction inside Objective-C++ bodies remains outside this provider.

1
eval/.gitignore vendored
View file

@ -14,4 +14,3 @@ build/
# Environment # Environment
.env .env
.venv/ .venv/
.venv

View file

@ -17,6 +17,7 @@ Usage:
import json import json
import logging import logging
import os
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path

View file

@ -14,6 +14,7 @@ import subprocess
import sys import sys
import threading import threading
import time import time
from pathlib import Path
from typing import Any from typing import Any
from constants import ( from constants import (

View file

@ -1,121 +0,0 @@
"""Require a real passing execution of every pytest case across the CI jobs."""
from __future__ import annotations
import json
from pathlib import Path
import sys
import xml.etree.ElementTree as ET
RECEIPTS = ("pytest-locked.xml", "pytest-ubuntu.xml", "pytest-windows.xml")
def reconcile(reports: list[Path], expected_files: list[str]) -> dict:
if not reports:
raise ValueError("No pytest execution reports supplied")
tests: dict[tuple[str, str], set[str]] = {}
durations: list[float] = []
for report in reports:
root = ET.parse(report).getroot()
suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite"))
if root.tag not in {"testsuite", "testsuites"} or not suites:
raise ValueError(f"Invalid pytest report: {report}")
seen: set[tuple[str, str]] = set()
duration = 0.0
for suite in suites:
cases = suite.findall("testcase")
if not cases:
raise ValueError(f"Empty pytest suite in {report}")
counts = {"tests": len(cases), "failures": 0, "errors": 0, "skipped": 0}
for case in cases:
key = (case.get("classname", ""), case.get("name", ""))
if not all(key) or key in seen:
raise ValueError(f"Missing or ambiguous pytest identity in {report}: {key}")
seen.add(key)
for status in ("failures", "errors", "skipped"):
tag = {"failures": "failure", "errors": "error", "skipped": "skipped"}[status]
counts[status] += len(case.findall(tag))
status = (
"failed"
if case.find("failure") is not None or case.find("error") is not None
else "pending"
if case.find("skipped") is not None
else "passed"
)
tests.setdefault(key, set()).add(status)
for name, count in counts.items():
if int(suite.get(name, "-1")) != count:
raise ValueError(f"Inconsistent pytest {name} count in {report}")
duration += float(suite.get("time", "0"))
durations.append(duration)
modules = {module for module, _ in tests}
for file in expected_files:
module = file.removesuffix(".py").replace("/", ".")
if not any(name == module or name.startswith(module + ".") for name in modules):
raise ValueError(f"Pytest file was never collected: {file}")
suites_by_module: dict[str, dict] = {}
failed: list[str] = []
pending = 0
for (module, name), statuses in sorted(tests.items()):
status = "failed" if "failed" in statuses else "passed" if "passed" in statuses else "pending"
full_name = f"{module}::{name}"
if status == "failed":
failed.append(full_name)
pending += status == "pending"
suite = suites_by_module.setdefault(
module,
{
"name": module,
"status": "passed",
"assertionResults": [],
"endTime": round(max(durations) * 1000),
},
)
suite["assertionResults"].append(
{
"fullName": full_name,
"ancestorTitles": [module],
"title": name,
"status": status,
}
)
if status == "failed":
suite["status"] = "failed"
return {
"success": not failed and pending == 0,
"numTotalTests": len(tests),
"numPassedTests": len(tests) - len(failed) - pending,
"numFailedTests": len(failed),
"numPendingTests": pending,
"numTotalTestSuites": len(suites_by_module),
"numFailedTestSuites": sum(s["status"] == "failed" for s in suites_by_module.values()),
"executionFailures": failed,
"startTime": 0,
"testResults": list(suites_by_module.values()),
}
def main() -> int:
if len(sys.argv) != 3:
raise ValueError("Usage: check_test_execution.py <reports-dir> <output.json>")
directory, output = map(Path, sys.argv[1:])
root = Path(__file__).resolve().parent
expected = [p.relative_to(root).as_posix() for p in (root / "tests").rglob("test_*.py")]
report = reconcile([directory / name for name in RECEIPTS], expected)
output.write_text(json.dumps(report), encoding="utf-8")
print(
f"Eval execution: {report['numPassedTests']}/{report['numTotalTests']} passed; "
f"{report['numPendingTests']} unverified; {report['numFailedTests']} failures"
)
for suite in report["testResults"]:
for case in suite["assertionResults"]:
if case["status"] != "passed":
print(f"{case['status']}: {case['fullName']}", file=sys.stderr)
return 0 if report["success"] else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -6,10 +6,7 @@ readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"mini-swe-agent>=2.0.0", "mini-swe-agent>=2.0.0",
"litellm[proxy]!=1.82.7,!=1.82.8,>=1.99.0", "litellm!=1.82.7,!=1.82.8,>=1.83.7",
"cryptography>=50.0.0",
"python-multipart>=0.0.30",
"restrictedpython>=8.3",
"datasets>=3.0.0", "datasets>=3.0.0",
"typer>=0.12.0", "typer>=0.12.0",
"rich>=13.0.0", "rich>=13.0.0",

View file

@ -27,10 +27,12 @@ import threading
import time import time
from itertools import product from itertools import product
from pathlib import Path from pathlib import Path
from typing import Any
import typer import typer
import yaml import yaml
from rich.console import Console from rich.console import Console
from rich.live import Live
from rich.table import Table from rich.table import Table
from utils.errors import is_debug_enabled, log_safe_exception from utils.errors import is_debug_enabled, log_safe_exception

View file

@ -1,75 +0,0 @@
"""Shared row shapes for the sweep tests.
Building the finalization tests turned up what a real scored review row must
carry: the report renders the whole review metric set, so an incomplete row
fails in string formatting rather than in the logic under test. That is a
property of the fixture, not of production - the shape lives here once so each
test does not rediscover it.
"""
from __future__ import annotations
from typing import Any
def scored_review_row(**overrides: Any) -> dict[str, Any]:
"""One admissible review cell, with zero-valued metrics written out."""
row: dict[str, Any] = {
"ok": True,
"error_kind": None,
"error_detail": None,
"resolved": True,
"review_evidence_valid": True,
"review_score": {"weighted_f1": 0.5},
"review_weighted_f1": 0.5,
"review_true_positives": 1,
"review_false_positives": 0,
"review_false_negatives": 0,
"review_precision": 0.5,
"review_recall": 0.5,
"review_f1": 0.5,
"review_weighted_precision": 0.5,
"review_weighted_recall": 0.5,
"review_blocker_recall": 1.0,
"review_severity_accuracy": 1.0,
"review_category_accuracy": 1.0,
"review_grounded_evidence": 1.0,
"review_verdict_correct": True,
"review_clean_control": True,
"review_clean_pass": True,
"transcript_missing": False,
"transcript_artifacts": [],
"num_turns": 3,
"duration_s": 1.0,
"cost_usd": 0.5,
"input_tokens": 1,
"output_tokens": 1,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"diff_files": 0,
"diff_insertions": 0,
"diff_deletions": 0,
}
row.update(overrides)
return row
def unusable_review_row(**overrides: Any) -> dict[str, Any]:
"""A cell that ran but produced evidence nothing can be scored from."""
# Merged into one mapping rather than passed as explicit keywords beside
# **overrides: Python rejects a duplicate keyword in the call expression
# itself, so unusable_review_row(error_kind=...) raised TypeError before
# scored_review_row could apply the override this helper advertises.
return scored_review_row(
**{
"ok": False,
"resolved": False,
"review_evidence_valid": False,
"error_kind": "review-evidence-invalid",
"review_score": None,
"review_weighted_f1": None,
**overrides,
}
)

View file

@ -1,175 +0,0 @@
#!/usr/bin/env python3
"""A stand-in for the Claude Code CLI: real HTTP, real tool execution, real stream-json.
Not a mock of the harness's own code. It does what the CLI does at the two
boundaries the harness depends on - it calls ANTHROPIC_BASE_URL for a turn, it
EXECUTES the tool blocks that come back, and it prints the stream-json event
sequence the parent parses. Only Write really executes - it is what produces the
review artifact, so the artifact path has to be genuine end to end. Skill is
MODELLED: it validates the request and returns a synthetic result, because the
parent's evidence gate keys on the request/result pair rather than on a skill
having loaded, and a fixture cannot load a real one. Bash is stubbed outright:
arbitrary shell from a scripted reply buys no fidelity for the paths this
exercises and plenty of ways to damage the host. Everything between
those boundaries (the sandbox,
the artifact capture, the scoring, the row) stays real, which is the whole
point: those are the layers that shipped bugs no unit test could see.
Reads the prompt from stdin, as the real CLI does under "-p --input-format text".
"""
from __future__ import annotations
import json
import os
import pathlib
import sys
import urllib.request
def _turn(base_url: str, prompt: str) -> dict:
request = urllib.request.Request(
base_url.rstrip("/") + "/v1/messages",
data=json.dumps({"model": os.environ.get("ANTHROPIC_MODEL", "mock"), "max_tokens": 1024,
"messages": [{"role": "user", "content": prompt}]}).encode(),
headers={"Content-Type": "application/json",
"x-api-key": os.environ.get("ANTHROPIC_API_KEY", ""),
"anthropic-version": "2023-06-01"},
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
def _run_tool(name: str, params: dict) -> str:
"""Write executes for real - it is what produces the review artifact.
Skill and Bash do not: see the module docstring for which is modelled and
which is stubbed, and why neither can be genuine here.
"""
if name == "Write":
target = pathlib.Path(params["file_path"])
target.parent.mkdir(parents=True, exist_ok=True)
# Atomic, exactly as the real Write tool does it: temp file beside the
# target, then rename. This is the operation the read-only workspace
# boundary has to permit for the artifact directory and refuse for the
# workspace, so a stand-in that wrote in place would prove nothing.
staging = target.with_name(target.name + ".tmp.fake")
staging.write_text(params.get("content", ""))
os.replace(staging, target)
return f"wrote {target}"
if name == "Skill":
# Modelled explicitly rather than falling through to a generic success.
# The parent's evidence gate keys on a Skill request with a non-error
# result, so leaving this unimplemented let an unexecuted skill satisfy
# the gate - the gate would have been measuring the fixture, not a skill.
skill = params.get("skill") or params.get("command") or params.get("name")
if not skill:
raise NotImplementedError("Skill request carried no skill name")
return f"loaded skill {skill}"
if name == "Bash":
return "(bash suppressed in the stand-in)"
# An unsupported tool is a FAILED tool run, not a quiet success. Returning a
# plain string here made the parent's evidence gate read an unexecuted Skill
# request as a successful invocation.
raise NotImplementedError(f"unsupported tool {name}")
def main() -> int:
# stdin, because that is where the real CLI takes it under
# "-p --input-format text": the parent pipes prompt bytes in. Scanning argv
# for a non-flag token picks up a flag's VALUE instead ("text"), which is
# exactly what the prompt-fidelity test caught.
prompt = sys.stdin.read()
base_url = os.environ.get("ANTHROPIC_BASE_URL")
if not base_url:
print(json.dumps({"type": "result", "subtype": "error", "is_error": True,
"session_id": "fake-session", "num_turns": 0}), flush=True)
return 1
emit = lambda event: print(json.dumps(event), flush=True) # noqa: E731
emit({"type": "system", "subtype": "init", "session_id": "fake-session"})
try:
message = _turn(base_url, prompt)
except (OSError, ValueError) as exc:
# A provider failure is a failed SESSION, not a crashed process: dying
# here leaves no terminal result event, so the parent reports a generic
# stream error instead of the upstream failure it actually saw.
emit({"type": "result", "subtype": "error", "is_error": True,
"session_id": "fake-session", "num_turns": 0,
"error": f"provider request failed: {type(exc).__name__}: {exc}"})
return 1
blocks = message.get("content", [])
emit({"type": "assistant", "message": {"role": "assistant", "content": blocks}})
tool_results = []
for block in blocks:
if block.get("type") == "tool_use":
# A refused write is a tool ERROR the session reports and carries
# on from, not a crash. Letting it kill the process would lose the
# result event and misreport a working boundary as a broken run.
failed = False
try:
output = _run_tool(block["name"], block.get("input", {}))
except (OSError, NotImplementedError) as exc:
output, failed = f"error: {type(exc).__name__}: {exc}", True
# is_error is load-bearing: the parent treats an ABSENT is_error as
# success, so a refused or unsupported tool would otherwise be
# scored as a completed one.
tool_results.append({
"type": "tool_result", "tool_use_id": block["id"],
"content": output, "is_error": failed,
})
if tool_results:
emit({"type": "user", "message": {"role": "user", "content": tool_results}})
# Unknown is not zero. A reply carrying no usage used to become four
# zero-valued fields plus a fabricated cost, which the harness then treats
# as a real measurement - the exact confusion the accounting this fixture
# feeds exists to prevent.
usage = message.get("usage")
# Every field that gets forwarded is validated, not just the required two.
# The parent's well_formed check tests only that the four keys are PRESENT,
# so an unvalidated cache value rides into a success result and is recorded
# as a real measurement. A field good enough to report is good enough to
# check.
countable = lambda v: isinstance(v, int) and not isinstance(v, bool) and v >= 0 # noqa: E731
if not isinstance(usage, dict) or not all(
countable(usage.get(f)) for f in ("input_tokens", "output_tokens")
) or not all(
countable(usage[f])
for f in ("cache_read_input_tokens", "cache_creation_input_tokens")
if f in usage
):
emit({"type": "result", "subtype": "error", "is_error": True,
"session_id": "fake-session", "num_turns": 1,
"error": "provider reply carried no usable usage; refusing to report a measured run"})
return 1
emit({
"type": "result",
"subtype": "success",
"is_error": False,
"session_id": "fake-session",
"num_turns": 1,
"duration_ms": 1200,
# A measured zero is not the same as unmeasured; the parent rejects a
# collapsed cost, so report a real one.
"total_cost_usd": 0.42,
# Forward exactly the fields the provider reported. Defaulting the
# absent ones to 0 fabricated a complete measurement out of an
# incomplete reply - and worse, it made the parent's own completeness
# check (runner_sessions.USAGE_FIELDS / well_formed) unfirable from any
# offline test, because the stand-in always satisfied it.
"usage": {
field: usage[field]
for field in ("input_tokens", "output_tokens",
"cache_read_input_tokens", "cache_creation_input_tokens")
if field in usage
},
})
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,142 +0,0 @@
"""The execution gate must reject absent, skipped, failed, or ambiguous evidence."""
from pathlib import Path
import json
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
import pytest
from check_test_execution import reconcile
def receipt(tmp_path: Path, filename: str, cases: list[tuple[str, str]], module="tests.test_native") -> Path:
suite = ET.Element("testsuite", tests=str(len(cases)), failures="0", errors="0", skipped="0", time="1.5")
for name, status in cases:
case = ET.SubElement(suite, "testcase", classname=module, name=name)
if status != "passed":
ET.SubElement(case, status)
count = {"failure": "failures", "error": "errors", "skipped": "skipped"}[status]
suite.set(count, str(int(suite.get(count)) + 1))
path = tmp_path / filename
ET.ElementTree(suite).write(path, encoding="utf-8")
return path
def test_platform_skip_requires_a_matching_real_pass(tmp_path):
linux = receipt(tmp_path, "linux.xml", [("test_windows", "skipped"), ("test_posix", "passed")])
windows = receipt(tmp_path, "windows.xml", [("test_windows", "passed"), ("test_posix", "skipped")])
report = reconcile([linux, windows], ["tests/test_native.py"])
assert report["success"] is True
assert report["numPassedTests"] == report["numTotalTests"] == 2
assert report["numPendingTests"] == report["numFailedTests"] == 0
def test_unresolved_skip_is_not_a_pass(tmp_path):
path = receipt(tmp_path, "report.xml", [("test_missing_runtime", "skipped")])
report = reconcile([path], [])
assert report["success"] is False
assert report["numPendingTests"] == 1
assert report["numPassedTests"] == 0
assert report["testResults"][0]["assertionResults"][0]["ancestorTitles"] == ["tests.test_native"]
@pytest.mark.parametrize("status", ["failure", "error"])
def test_failure_is_not_hidden_by_another_job_passing(tmp_path, status):
failed = receipt(tmp_path, "failed.xml", [("test_shared", status)])
passed = receipt(tmp_path, "passed.xml", [("test_shared", "passed")])
report = reconcile([failed, passed], [])
assert report["success"] is False
assert report["numFailedTests"] == 1
assert report["numPassedTests"] == 0
assert report["executionFailures"] == ["tests.test_native::test_shared"]
def test_same_title_in_another_file_does_not_resolve_a_skip(tmp_path):
pending = receipt(tmp_path, "pending.xml", [("test_same", "skipped")])
unrelated = receipt(tmp_path, "unrelated.xml", [("test_same", "passed")], module="tests.test_other")
report = reconcile([pending, unrelated], [])
assert report["numTotalTests"] == 2
assert report["numPendingTests"] == 1
def test_duplicate_identity_is_rejected(tmp_path):
path = receipt(tmp_path, "duplicate.xml", [("test_same", "passed"), ("test_same", "skipped")])
with pytest.raises(ValueError, match="ambiguous pytest identity"):
reconcile([path], [])
def test_missing_receipt_is_rejected(tmp_path):
with pytest.raises(FileNotFoundError):
reconcile([tmp_path / "missing.xml"], [])
def test_empty_receipt_is_rejected(tmp_path):
path = receipt(tmp_path, "empty.xml", [])
with pytest.raises(ValueError, match="Empty pytest suite"):
reconcile([path], [])
def test_missing_file_is_rejected(tmp_path):
path = receipt(tmp_path, "report.xml", [("test_present", "passed")])
with pytest.raises(ValueError, match="never collected: tests/test_missing.py"):
reconcile([path], ["tests/test_missing.py"])
def test_inconsistent_counts_are_rejected(tmp_path):
path = receipt(tmp_path, "report.xml", [("test_present", "passed")])
path.write_text(path.read_text().replace('tests="1"', 'tests="2"'))
with pytest.raises(ValueError, match="Inconsistent pytest tests count"):
reconcile([path], [])
def test_pytest_testsuites_wrapper_and_test_classes(tmp_path):
path = receipt(tmp_path, "report.xml", [("test_method", "passed")], module="tests.test_native.TestNative")
suite = ET.parse(path).getroot()
root = ET.Element("testsuites")
root.append(suite)
ET.ElementTree(root).write(path, encoding="utf-8")
assert reconcile([path], ["tests/test_native.py"])["success"] is True
def test_missing_report_list_is_rejected():
with pytest.raises(ValueError, match="No pytest execution reports"):
reconcile([], [])
@pytest.mark.parametrize("scenario", ["passed", "pending", "failed", "missing-receipt", "missing-file"])
def test_cli_requires_complete_execution_evidence(tmp_path, scenario):
script = tmp_path / "check_test_execution.py"
shutil.copyfile(Path(__file__).parents[1] / script.name, script)
tests = tmp_path / "tests"
tests.mkdir()
(tests / "test_native.py").write_text("# inventory fixture\n")
reports = tmp_path / "reports"
reports.mkdir()
receipt(reports, "pytest-locked.xml", [("test_native", "skipped" if scenario == "pending" else "passed")])
receipt(reports, "pytest-ubuntu.xml", [("test_native", "error" if scenario == "failed" else "skipped")])
if scenario != "missing-receipt":
receipt(reports, "pytest-windows.xml", [("test_native", "skipped")])
if scenario == "missing-file":
(tests / "test_missing.py").write_text("# must be collected\n")
output = tmp_path / "pytest-results.json"
result = subprocess.run(
[sys.executable, str(script), str(reports), str(output)],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == (0 if scenario == "passed" else 1)
if scenario.startswith("missing-"):
assert not output.exists()
assert ("pytest-windows.xml" if scenario == "missing-receipt" else "never collected") in result.stderr
else:
report = json.loads(output.read_text())
assert report["success"] is (scenario == "passed")
assert report["numTotalTests"] == 1
assert report["numPassedTests"] == (scenario == "passed")
assert report["numPendingTests"] == (scenario == "pending")
assert report["numFailedTests"] == (scenario == "failed")
assert "Eval execution:" in result.stdout

View file

@ -1,467 +0,0 @@
"""Comparator-row reuse: skip unchanged incumbent/CE cells, never candidates."""
from __future__ import annotations
import hashlib
import os
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from workflow_bench import comparator_reuse
from workflow_bench.comparator_reuse import (
ComparatorReuseExpectation,
TaskReuseBinding,
materialize_reused_row,
row_is_reusable_comparator,
select_reusable_comparator_rows,
)
from workflow_bench.proposer_sandbox import SandboxError
from workflow_bench.runner_sessions import PARENT_EVENT_STREAM_SOURCE
requires_openat = pytest.mark.skipif(
os.open not in os.supports_dir_fd,
reason="comparator reuse resolves every artifact against a pinned directory descriptor",
)
def _digest(text: str = "blob") -> str:
return hashlib.sha256(text.encode()).hexdigest()
def _artifact(name: str = "session-1.jsonl", payload: bytes = b'{"type":"ok"}\n') -> dict:
return {
"path": f"transcripts/{name}",
"sha256": hashlib.sha256(payload).hexdigest(),
"bytes": len(payload),
"source": PARENT_EVENT_STREAM_SOURCE,
}
def _row(**overrides) -> dict:
base = {
"task": "review-pr-2718-defect",
"arm": "review",
"run": 0,
"ok": True,
"error_kind": None,
"model": "gpt-5.6-sol",
"benchmark_model": "gpt-5.6-sol",
"effort": "xhigh",
"sandbox_backend": "bwrap",
"task_base_sha": "a" * 40,
"task_prompt_digest": _digest("prompt"),
"oracle_digest": _digest("oracle"),
"oracle_command_digest": _digest("oracle-cmd"),
"oracle_manifest_digest": _digest("oracle-man"),
"skill_digest": _digest("skill"),
"candidate_overlay_digest": None,
"review_evidence_valid": True,
# Production sets this whenever the review source exists, which is the
# normal path for a valid review; the fixture predated the requirement.
"review_artifact": "review-pr-2718-defect-review-run0.review.json",
"review_score": {"weighted_f1": 0.4},
"review_weighted_f1": 0.4,
"transcript_missing": False,
"transcript_artifacts": [_artifact()],
"recorded_at": datetime.now(UTC).isoformat(),
"runtime_digest": _digest("cli"),
"task_asset_manifest_digest": _digest("assets"),
"sandbox_dependency_manifest_digest": _digest("deps"),
}
base.update(overrides)
return base
def _expected(**overrides) -> ComparatorReuseExpectation:
now = datetime.now(UTC)
values = dict(
model="gpt-5.6-sol",
effort="xhigh",
sandbox_backend="bwrap",
runtime_digest=_digest("cli"),
now=now,
max_age=timedelta(days=90),
tasks={
"review-pr-2718-defect": TaskReuseBinding(
task_base_sha="a" * 40,
task_prompt_digest=_digest("prompt"),
oracle_digest=_digest("oracle"),
oracle_command_digest=_digest("oracle-cmd"),
oracle_manifest_digest=_digest("oracle-man"),
task_asset_manifest_digest=_digest("assets"),
sandbox_dependency_manifest_digest=_digest("deps"),
)
},
skill_digests={"review": _digest("skill"), "ce_review": None},
ce_plugin_version="3.24.0",
ce_plugin_manifest_digest=_digest("ce"),
)
values.update(overrides)
return ComparatorReuseExpectation(**values)
def test_matching_incumbent_review_row_is_reusable() -> None:
assert row_is_reusable_comparator(_row(), _expected()) is True
def test_candidate_rows_are_never_reusable() -> None:
assert row_is_reusable_comparator(_row(arm="candidate_review"), _expected()) is False
def test_skill_digest_drift_rejects_reuse() -> None:
assert row_is_reusable_comparator(_row(), _expected(skill_digests={"review": _digest("other")})) is False
def test_excluded_or_failed_rows_are_not_reusable() -> None:
expected = _expected()
assert row_is_reusable_comparator(_row(error_kind="session-error", ok=False), expected) is False
assert row_is_reusable_comparator(_row(ok=False), expected) is False
assert row_is_reusable_comparator(_row(review_evidence_valid=False), expected) is False
assert row_is_reusable_comparator(_row(recorded_at=(datetime.now(UTC) - timedelta(days=91)).isoformat()), expected) is False
def test_runtime_digest_mismatch_rejects_when_both_sides_are_bound() -> None:
row = _row(runtime_digest=_digest("old-cli"))
assert row_is_reusable_comparator(row, _expected(runtime_digest=_digest("new-cli"))) is False
assert row_is_reusable_comparator(row, _expected(runtime_digest=_digest("old-cli"))) is True
# A row with no runtime_digest was measured by a harness that recorded none,
# which is the drift this lock exists to catch - not evidence of agreement.
assert row_is_reusable_comparator(_row(runtime_digest=None), _expected()) is False
# And a sweep that cannot determine its own digest must not reuse either.
assert row_is_reusable_comparator(_row(), _expected(runtime_digest=None)) is False
def test_ce_review_matches_plugin_digest_not_repo_skill() -> None:
row = _row(
arm="ce_review",
skill_digest=None,
ce_plugin_version="3.24.0",
ce_plugin_manifest_digest=_digest("ce"),
)
assert row_is_reusable_comparator(row, _expected()) is True
assert (
row_is_reusable_comparator(row, _expected(ce_plugin_manifest_digest=_digest("other")))
is False
)
def test_select_drops_conflicting_duplicates() -> None:
first = _row(review_weighted_f1=0.4)
second = _row(review_weighted_f1=0.9, recorded_at=datetime.now(UTC).isoformat())
selected = select_reusable_comparator_rows([first, second], expected=_expected())
assert selected == {}
same = select_reusable_comparator_rows([first, dict(first)], expected=_expected())
assert ("review-pr-2718-defect", "review", 0) in same
@requires_openat
def test_materialize_copies_transcript_and_review_artifacts(tmp_path: Path) -> None:
payload = b'{"type":"result"}\n'
source = tmp_path / "prior"
dest = tmp_path / "fresh"
(source / "transcripts").mkdir(parents=True)
dest.mkdir()
transcript = source / "transcripts" / "session-1.jsonl"
transcript.write_bytes(payload)
transcript.chmod(0o600)
review = source / "review-pr-2718-defect-review-run0.review.json"
review.write_text('{"verdict":"comment"}\n')
patch = source / "review-pr-2718-defect-review-run0.patch"
patch.write_text("diff\n")
row = _row(
review_artifact=review.name,
transcript_artifacts=[_artifact(payload=payload)],
)
copied = materialize_reused_row(row, source_dir=source, dest_dir=dest)
assert copied["reused"] is True
assert copied["reused_from_recorded_at"] == row["recorded_at"]
assert (dest / "transcripts" / "session-1.jsonl").read_bytes() == payload
assert (dest / review.name).read_text() == review.read_text()
assert (dest / patch.name).read_text() == "diff\n"
assert copied["transcript_artifacts"][0]["sha256"] == hashlib.sha256(payload).hexdigest()
@pytest.mark.skipif(os.name == "nt", reason="symlink creation may require elevated Windows privileges")
@requires_openat
def test_a_reused_artifact_is_copied_from_the_inode_that_was_checked(tmp_path: Path) -> None:
"""The reuse source is a directory another sweep wrote and may still write.
Validating a path and then re-opening it hands a concurrent writer the gap:
replace the checked file with a symlink and the copy follows it out of the
results directory. Swapping the path while the descriptor is held is that
same substitution, made deterministic.
"""
(tmp_path / "transcript.jsonl").write_bytes(b"verified\n")
decoy = tmp_path / "decoy.jsonl"
decoy.write_bytes(b"substituted\n")
with comparator_reuse._open_real_directory(tmp_path, label="reuse source") as dir_fd:
with comparator_reuse._open_regular("transcript.jsonl", dir_fd=dir_fd, label="transcript") as descriptor:
(tmp_path / "transcript.jsonl").unlink()
(tmp_path / "transcript.jsonl").symlink_to(decoy)
comparator_reuse._copy_owner_only(descriptor, "copy.jsonl", dir_fd=dir_fd)
assert (tmp_path / "copy.jsonl").read_bytes() == b"verified\n"
with pytest.raises(SandboxError, match="regular non-symlink"):
with comparator_reuse._open_regular("transcript.jsonl", dir_fd=dir_fd, label="transcript"):
pass
@pytest.mark.skipif(os.name == "nt", reason="symlink creation may require elevated Windows privileges")
@requires_openat
def test_a_symlinked_transcripts_directory_is_refused_on_both_sides(tmp_path: Path) -> None:
"""`O_NOFOLLOW` refuses the leaf, not the directory above it.
A `transcripts` symlink on the source side makes reuse read a file outside
the results directory; one on the destination side writes the copy outside
this sweep's evidence. Neither is covered by the per-file guards that let
_resolved_directory tolerate a symlinked root.
"""
payload = b'{"type":"result"}\n'
outside = tmp_path / "outside"
(outside / "transcripts").mkdir(parents=True)
(outside / "transcripts" / "session-1.jsonl").write_bytes(payload)
row = _row(transcript_artifacts=[_artifact(payload=payload)])
linked_source = tmp_path / "linked-source"
linked_source.mkdir()
(linked_source / "transcripts").symlink_to(outside / "transcripts", target_is_directory=True)
dest = tmp_path / "fresh"
dest.mkdir()
with pytest.raises(SandboxError, match="transcript source must be a real directory"):
materialize_reused_row(row, source_dir=linked_source, dest_dir=dest)
source = tmp_path / "prior"
(source / "transcripts").mkdir(parents=True)
(source / "transcripts" / "session-1.jsonl").write_bytes(payload)
linked_dest = tmp_path / "linked-dest"
linked_dest.mkdir()
(linked_dest / "transcripts").symlink_to(outside / "transcripts", target_is_directory=True)
with pytest.raises(SandboxError, match="transcript destination must be a real directory"):
materialize_reused_row(row, source_dir=source, dest_dir=linked_dest)
@requires_openat
@pytest.mark.skipif(os.name == "nt", reason="symlink creation may require elevated Windows privileges")
def test_a_renamed_transcripts_directory_cannot_redirect_a_copy(tmp_path: Path) -> None:
"""The directory is pinned, not re-walked from its name.
An lstat that passed and a pathname used afterwards are two different
directories the moment a concurrent writer renames the first one away. This
performs exactly that substitution — rename, then leave a symlink in its
place — while the descriptor is held, which is what makes the race testable
without timing.
"""
payload = b'{"type":"result"}\n'
results = tmp_path / "results"
transcripts = results / "transcripts"
transcripts.mkdir(parents=True)
(transcripts / "session-1.jsonl").write_bytes(payload)
outside = tmp_path / "outside"
outside.mkdir()
with comparator_reuse._open_real_directory(results, label="reuse source") as root_fd:
with comparator_reuse._open_real_directory(
"transcripts", dir_fd=root_fd, label="transcript source"
) as dir_fd:
transcripts.rename(results / "moved")
(results / "transcripts").symlink_to(outside, target_is_directory=True)
with comparator_reuse._open_regular(
"session-1.jsonl", dir_fd=dir_fd, label="transcript"
) as artifact_fd:
comparator_reuse._copy_owner_only(artifact_fd, "copy.jsonl", dir_fd=dir_fd)
assert (results / "moved" / "copy.jsonl").read_bytes() == payload
assert not (outside / "copy.jsonl").exists()
@requires_openat
def test_a_transcript_rewritten_mid_copy_is_refused_not_recorded(tmp_path: Path, monkeypatch) -> None:
"""The digest has to describe the bytes that were written.
A held descriptor stops the pathname being substituted; it does not stop the
inode being rewritten, and the prior sweep's directory is one this sweep
treats as concurrently writable. Hashing the source and then reading it
again to copy let the row keep the expected digest while the destination
held different bytes.
"""
payload = b'{"type":"result"}\n'
source = tmp_path / "prior"
(source / "transcripts").mkdir(parents=True)
transcript = source / "transcripts" / "session-1.jsonl"
transcript.write_bytes(payload)
dest = tmp_path / "fresh"
dest.mkdir()
row = _row(transcript_artifacts=[_artifact(payload=payload)])
# Rewrite the inode in the window the copy reads through — same length, so
# only the digest can tell, which is the point.
real_read = comparator_reuse.os.read
rewritten = {"done": False}
def rewrite_then_read(fd: int, size: int) -> bytes:
if not rewritten["done"]:
rewritten["done"] = True
with open(transcript, "r+b") as handle:
handle.write(b'{"type":"TAMPER"}')
return real_read(fd, size)
monkeypatch.setattr(comparator_reuse.os, "read", rewrite_then_read)
with pytest.raises(SandboxError, match="drifted"):
materialize_reused_row(row, source_dir=source, dest_dir=dest)
monkeypatch.undo()
# And nothing unvouched-for is left behind for the proposer to read.
assert not (dest / "transcripts" / "session-1.jsonl").exists()
@requires_openat
def test_materialize_rejects_same_directory_and_missing_transcript(tmp_path: Path) -> None:
source = tmp_path / "prior"
source.mkdir()
row = _row()
with pytest.raises(SandboxError, match="same results directory"):
materialize_reused_row(row, source_dir=source, dest_dir=source)
dest = tmp_path / "fresh"
dest.mkdir()
with pytest.raises(SandboxError, match="missing"):
materialize_reused_row(row, source_dir=source, dest_dir=dest)
@requires_openat
def test_a_reused_row_ages_from_its_first_measurement_not_the_copy():
"""Reuse chains must not refresh the clock.
materialize_reused_row restamps recorded_at with the copy time, so aging
against that field let a row be copied forward every generation and outlive
max_age forever. The original measurement time is the one that counts.
"""
original = (datetime.now(UTC) - timedelta(days=91)).isoformat()
chained = _row(recorded_at=datetime.now(UTC).isoformat(), reused_from_recorded_at=original)
assert row_is_reusable_comparator(chained, _expected()) is False
# The same row inside the window is still reusable.
fresh = _row(
recorded_at=datetime.now(UTC).isoformat(),
reused_from_recorded_at=(datetime.now(UTC) - timedelta(days=1)).isoformat(),
)
assert row_is_reusable_comparator(fresh, _expected()) is True
def test_a_future_dated_row_is_corrupt_not_fresh():
ahead = (datetime.now(UTC) + timedelta(days=2)).isoformat()
assert row_is_reusable_comparator(_row(recorded_at=ahead), _expected()) is False
def test_a_changed_sandbox_dependency_is_not_the_same_baseline():
"""The environment is part of the measurement.
This branch itself changes `sandbox_dependencies` in the review corpus, so a
prior row measured against the old set is a measurement of a different
machine. Reusing it would compare a fresh candidate to a baseline built
somewhere else and hand the promotion gate a false comparison.
"""
assert row_is_reusable_comparator(
_row(sandbox_dependency_manifest_digest=_digest("other-deps")), _expected()
) is False
assert row_is_reusable_comparator(
_row(task_asset_manifest_digest=_digest("other-assets")), _expected()
) is False
# A row that predates the field is not evidence of agreement either.
assert row_is_reusable_comparator(_row(sandbox_dependency_manifest_digest=None), _expected()) is False
@pytest.mark.skipif(os.name == "nt", reason="symlink creation may require elevated Windows privileges")
def test_reuse_directories_allow_a_symlinked_parent_but_not_a_symlinked_leaf(tmp_path: Path):
"""Pins a deliberate difference from the sandbox's mount-root check.
proposer_sandbox refuses every symlink hop because a hop changes what an
untrusted session is handed. A reuse directory is data, and every file
inside it is validated on its own, so a symlinked parent is allowed -
rejecting it would break a symlinked artifacts directory or macOS's /var
for no gain. The leaf itself must still be a real directory.
"""
real = tmp_path / "real"
real.mkdir()
(real / "inner").mkdir()
linked_parent = tmp_path / "linked"
linked_parent.symlink_to(real, target_is_directory=True)
# Reached through a symlinked parent: allowed, and resolved to the real path.
# The identity returned alongside it is what pins the root against a swap
# between the check and the open; the symlink policy itself is unchanged.
resolved, identity = comparator_reuse._resolved_directory(linked_parent / "inner", label="probe")
assert resolved == (real / "inner").resolve()
inner_stat = (real / "inner").stat()
assert identity == (inner_stat.st_dev, inner_stat.st_ino)
# The leaf itself being a symlink is still refused.
with pytest.raises(SandboxError, match="must be a real directory"):
comparator_reuse._resolved_directory(linked_parent, label="probe")
def test_a_review_row_without_its_artifact_is_not_reusable() -> None:
"""A score is a claim about evidence, not the evidence itself.
materialize_reused_row copies the review artifact only when the row names
one, so accepting a row without it would carry a scored review forward with
nothing for a proposer to read.
"""
row = _row()
assert row_is_reusable_comparator(row, _expected()) is True
without = {**row, "review_artifact": ""}
assert row_is_reusable_comparator(without, _expected()) is False
missing = {k: v for k, v in row.items() if k != "review_artifact"}
assert row_is_reusable_comparator(missing, _expected()) is False
@requires_openat
def test_a_reuse_root_replaced_after_the_check_is_refused(tmp_path: Path, monkeypatch) -> None:
"""Check and use must name the same directory, not the same string.
_resolved_directory lstats a name and the open re-walks that same name, so
a prior sweep that swaps its results root in between is opened somewhere
else. The leaf-symlink rule does not cover it - a replacement that is
itself a real directory passes every check the policy makes - and the
failure is silent, folding another directory's rows into this sweep's
comparator baseline.
"""
original = tmp_path / "results"
original.mkdir()
resolved, stale_identity = comparator_reuse._resolved_directory(original, label="probe")
# Replaced by a different REAL directory: the name still resolves and still
# passes the symlink policy, but it is not the inode that was checked.
original.rename(tmp_path / "moved")
original.mkdir()
assert comparator_reuse._resolved_directory(original, label="probe")[1] != stale_identity
monkeypatch.setattr(
comparator_reuse, "_resolved_directory", lambda *_a, **_k: (resolved, stale_identity)
)
with pytest.raises(SandboxError, match="replaced between the check and the open"):
with comparator_reuse._open_pinned_root(original, label="probe"):
pass
@requires_openat
def test_a_stable_reuse_root_opens_normally(tmp_path: Path) -> None:
"""The guard rejects nothing that holds still - a directory matches itself."""
root = tmp_path / "results"
root.mkdir()
with comparator_reuse._open_pinned_root(root, label="probe") as fd:
assert os.fstat(fd).st_ino == root.stat().st_ino

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
"""Tests for MCPBridge._find_gitnexus_command() and subprocess spawn.""" """Tests for MCPBridge._find_gitnexus_command() and subprocess spawn."""
import subprocess import subprocess
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, call, patch
class TestFindGitnexusCommand(unittest.TestCase): class TestFindGitnexusCommand(unittest.TestCase):

View file

@ -1,122 +0,0 @@
"""Cost model for the evolution wall clock: measured cells, real schedules."""
from __future__ import annotations
import pytest
from workflow_bench.measure_evolution_cost import (
CANDIDATE_ARM,
SHA_OVERHEAD_SECONDS,
DURATIONS_BY_ARM,
PROPOSER_SECONDS,
REVIEW_ARMS,
expected_task_seconds,
fed_makespan,
fed_pool_enabled,
generation_seconds,
graph_pipeline_enabled,
paid_arms,
task_cells,
wave_makespan,
)
def test_every_arm_has_its_own_unsorted_sample():
assert set(DURATIONS_BY_ARM) == set(REVIEW_ARMS)
for arm, sample in DURATIONS_BY_ARM.items():
assert len(sample) >= 10, arm
# Sorting would hand each task a uniform block and hide the variance
# the whole model exists to price.
assert list(sample) != sorted(sample), arm
assert PROPOSER_SECONDS > 0
assert SHA_OVERHEAD_SECONDS > 0
def test_weekly_reuse_pays_the_candidate_arm_only():
assert paid_arms(weekly=True, reuse_enabled=True) == (CANDIDATE_ARM,)
assert paid_arms(weekly=False, reuse_enabled=True) == REVIEW_ARMS
assert paid_arms(weekly=True, reuse_enabled=False) == REVIEW_ARMS
def test_cells_are_submitted_run_major_arm_minor():
# runner.py: [(run_idx, arm) for run_idx in range(runs) for arm in arms].
# At workers=3 that puts one cell of each arm in every wave.
cells = task_cells(2, REVIEW_ARMS, 0)
assert len(cells) == 6
expected = [DURATIONS_BY_ARM[arm][run] for run in range(2) for arm in REVIEW_ARMS]
assert cells == expected
def test_overhead_is_charged_per_sha_and_outside_the_pool():
# Two properties at once: the residual sits outside the schedule, where more
# workers cannot dissolve it, and it scales with SHAs rather than cells.
assert task_cells(1, (CANDIDATE_ARM,), 0) == [DURATIONS_BY_ARM[CANDIDATE_ARM][0]]
wide = generation_seconds(
task_count=1, runs=3, arms=REVIEW_ARMS, workers=9, fed_pool=True, unique_shas=5
)
assert wide >= PROPOSER_SECONDS + 5 * SHA_OVERHEAD_SECONDS
def test_sweep_overhead_does_not_shrink_with_the_arm_count():
"""The bias that made weekly look cheaper than it is.
A seeded weekly generation pays one arm instead of three but builds exactly
the same graphs. Charging the residual per cell billed it a third of a cost
the real sweep still pays; per SHA, the two attribute the same setup.
"""
kwargs = dict(task_count=6, runs=3, workers=3, fed_pool=False, unique_shas=5)
weekly = generation_seconds(arms=(CANDIDATE_ARM,), **kwargs)
cold = generation_seconds(arms=REVIEW_ARMS, **kwargs)
weekly_sessions = 6 * expected_task_seconds(3, (CANDIDATE_ARM,), 3, fed_pool=False)
cold_sessions = 6 * expected_task_seconds(3, REVIEW_ARMS, 3, fed_pool=False)
# Whatever each wall is, the non-session part is identical.
assert round(weekly - weekly_sessions) == round(cold - cold_sessions)
# Cycling wraps, so a task can ask for more runs than the sample holds.
long_sample = task_cells(len(DURATIONS_BY_ARM[CANDIDATE_ARM]) + 2, (CANDIDATE_ARM,), 0)
assert len(long_sample) == len(DURATIONS_BY_ARM[CANDIDATE_ARM]) + 2
def test_a_wave_costs_its_slowest_cell_and_a_fed_pool_does_not():
slow = [10.0, 1.0, 1.0, 10.0, 1.0, 1.0]
assert wave_makespan(slow, 3) == 20.0
# Fed: one worker takes the first 10; the second 10 lands on a worker that
# has already cleared a 1, and the remaining 1s fill the third.
assert fed_makespan(slow, 3) == 11.0
assert fed_makespan(slow, 1) == wave_makespan(slow, 1) == 24.0
def test_expected_task_seconds_is_alignment_averaged_and_deterministic():
waved = expected_task_seconds(3, REVIEW_ARMS, 3, fed_pool=False)
assert waved == expected_task_seconds(3, REVIEW_ARMS, 3, fed_pool=False)
assert expected_task_seconds(0, REVIEW_ARMS, 3, fed_pool=False) == 0.0
assert expected_task_seconds(3, (), 3, fed_pool=False) == 0.0
# The barrier can only cost time, never save it.
assert waved >= expected_task_seconds(3, REVIEW_ARMS, 3, fed_pool=True)
def test_a_generation_pays_one_proposer_session_on_top_of_its_tasks():
one = generation_seconds(
task_count=1, runs=3, arms=REVIEW_ARMS, workers=3, fed_pool=False, unique_shas=1
)
two = generation_seconds(
task_count=2, runs=3, arms=REVIEW_ARMS, workers=3, fed_pool=False, unique_shas=1
)
# Each extra task adds exactly one task's makespan. The proposer and the
# per-SHA sweep overhead are both paid once, not per task.
assert two - one == pytest.approx(
one - PROPOSER_SECONDS - SHA_OVERHEAD_SECONDS, abs=2.0
)
def test_feature_flags_read_the_runner_not_the_wish():
assert graph_pipeline_enabled("def _run_sweep(): pass") == 0
assert graph_pipeline_enabled("graph_prefetch = GraphPrefetch(...)") == 1
assert fed_pool_enabled("def _run_wave(): pass") == 0
assert fed_pool_enabled("def _run_fed_pool(): pass") == 1
@pytest.mark.parametrize("workers", [1, 3, 8])
def test_more_workers_never_lengthen_a_task(workers):
serial = expected_task_seconds(3, REVIEW_ARMS, 1, fed_pool=True)
assert expected_task_seconds(3, REVIEW_ARMS, workers, fed_pool=True) <= serial

View file

@ -1,280 +0,0 @@
"""The mock has to be right about the wire, or every test built on it lies."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import urllib.request
from pathlib import Path
import pytest
from workflow_bench.mock_provider import MockProvider, Reply
from workflow_bench.provider_usage import (
ANTHROPIC,
LITELLM_NORMALIZED,
OPENAI_RESPONSES,
normalize_usage,
)
def _post(url: str, payload: dict) -> tuple[int, bytes]:
request = urllib.request.Request(
url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(request, timeout=10) as response:
return response.status, response.read()
def test_anthropic_messages_returns_a_usable_message() -> None:
with MockProvider([Reply(text="reviewed")]) as provider:
_status, raw = _post(provider.base_url + "/v1/messages", {"model": "m", "messages": []})
body = json.loads(raw)
assert body["role"] == "assistant"
assert body["content"][0]["text"] == "reviewed"
assert body["stop_reason"] == "end_turn"
def test_a_scripted_tool_call_is_carried_as_a_tool_use_block() -> None:
"""Tool blocks are how a mocked run produces real artifacts.
The CLI executes what it is asked to run, so a Write block makes it write
that file for real inside the sandbox - which is how an artifact-producing
cell can be exercised with no model involved.
"""
write = {"name": "Write", "input": {"file_path": "/review-output/review-output.json", "content": "{}"}}
with MockProvider([Reply(text="writing", tools=[write])]) as provider:
_status, raw = _post(provider.base_url + "/v1/messages", {"model": "m", "messages": []})
body = json.loads(raw)
block = body["content"][1]
assert block["type"] == "tool_use" and block["name"] == "Write"
assert block["input"]["file_path"] == "/review-output/review-output.json"
assert body["stop_reason"] == "tool_use", "a turn ending in a tool call must say so"
def test_streaming_emits_the_event_sequence_a_consumer_expects() -> None:
with MockProvider([Reply(text="hi")]) as provider:
request = urllib.request.Request(
provider.base_url + "/v1/messages",
data=json.dumps({"model": "m", "messages": [], "stream": True}).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=10) as response:
assert response.headers["Content-Type"] == "text/event-stream"
body = response.read().decode()
events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")]
assert events[0] == "message_start"
assert events[-1] == "message_stop"
assert "content_block_delta" in events
# message_delta carries the final usage, which is where output tokens land.
assert events[-2] == "message_delta"
def test_each_protocol_reports_usage_in_its_own_arithmetic() -> None:
"""The whole point: the two providers count the same numbers differently.
Anthropic's cache fields ADD to input_tokens; OpenAI's are SUBSETS of it.
Scripting one Reply and serving it both ways is what makes that asymmetry
testable without a paid request.
"""
reply = Reply(input_tokens=2_000, output_tokens=300, cache_read_input_tokens=7_000, cache_creation_input_tokens=1_000)
with MockProvider([reply, reply]) as provider:
_s, anthropic_raw = _post(provider.base_url + "/v1/messages", {"model": "m", "messages": []})
_s, openai_raw = _post(provider.base_url + "/v1/responses", {"model": "m", "input": []})
anthropic = normalize_usage(ANTHROPIC, json.loads(anthropic_raw)["usage"])
openai = normalize_usage(OPENAI_RESPONSES, json.loads(openai_raw)["usage"])
assert anthropic.total_input_tokens == 10_000
assert openai.total_input_tokens == 10_000, "same billed work, stated as the whole"
assert anthropic.ordinary_input_tokens == 2_000
assert openai.ordinary_input_tokens == 2_000, "recovered by subtraction, not addition"
assert openai.cache_read_input_tokens == 7_000
def test_a_scripted_failure_is_returned_as_one() -> None:
"""Billed failures are part of what the accounting must survive."""
with MockProvider([Reply(status_code=529, error_body={"error": {"type": "overloaded_error"}})]) as provider:
try:
_post(provider.base_url + "/v1/messages", {"model": "m", "messages": []})
raise AssertionError("the scripted failure was not returned")
except urllib.error.HTTPError as exc:
assert exc.code == 529
def test_requests_are_recorded_for_assertions() -> None:
with MockProvider() as provider:
_post(provider.base_url + "/v1/messages", {"model": "claude-sonnet-4-5", "messages": [{"role": "user"}]})
assert len(provider.requests) == 1
assert provider.requests[0].body["model"] == "claude-sonnet-4-5"
assert provider.requests[0].path.endswith("/v1/messages")
def test_an_unscripted_turn_gets_the_default_rather_than_stalling() -> None:
"""A real run makes more calls than a test wants to enumerate."""
with MockProvider([Reply(text="first")], default=Reply(text="fallback")) as provider:
_s, one = _post(provider.base_url + "/v1/messages", {"model": "m", "messages": []})
_s, two = _post(provider.base_url + "/v1/messages", {"model": "m", "messages": []})
assert json.loads(one)["content"][0]["text"] == "first"
assert json.loads(two)["content"][0]["text"] == "fallback"
def test_a_request_through_the_real_gateway_records_native_usage(tmp_path, monkeypatch) -> None:
"""The whole stack minus the model: proxy, translation, callback, log.
This is the path that shipped three separate defects invisible to unit
tests - the usage variable never reaching the proxy subprocess, the
callback failing to import when loaded by path, and failures never
recorded. All three live between the gateway and the provider, which is
exactly the span this exercises.
"""
import yaml
from workflow_bench import model_gateway
from workflow_bench.model_gateway import OpenAIGateway
from workflow_bench.provider_usage import USAGE_LOG_ENV_VAR
if shutil.which("litellm") is None:
import pytest
pytest.skip("litellm console script absent; the proxy cannot start here")
usage_log = tmp_path / "provider_usage.jsonl"
monkeypatch.setenv(USAGE_LOG_ENV_VAR, str(usage_log))
reply = Reply(input_tokens=2_000, output_tokens=300, cache_read_input_tokens=7_000, cache_creation_input_tokens=1_000)
with MockProvider(default=reply) as provider:
original = model_gateway.write_openai_litellm_config
def config(path, names):
original(path, names)
document = yaml.safe_load(path.read_text())
for entry in document["model_list"]:
entry["litellm_params"]["api_base"] = f"{provider.base_url}/v1"
path.write_text(yaml.safe_dump(document))
return path
monkeypatch.setattr(model_gateway, "write_openai_litellm_config", config)
with OpenAIGateway(
openai_api_key="mock-key", model_names=["gpt-4.1"], work_dir=tmp_path / "gw", ready_timeout_s=60
) as gateway:
request = urllib.request.Request(
gateway.base_url + "/v1/messages",
data=json.dumps({"model": "gpt-4.1", "max_tokens": 32, "messages": [{"role": "user", "content": "ping"}]}).encode(),
headers={"Content-Type": "application/json", "x-api-key": gateway.auth_token, "anthropic-version": "2023-06-01"},
)
with urllib.request.urlopen(request, timeout=60):
pass
assert usage_log.exists(), "the callback never wrote - the env did not reach the proxy"
events = [json.loads(line) for line in usage_log.read_text().splitlines()]
assert events, "the proxy started but recorded nothing"
event = events[-1]
native = event["native_usage"]
# LiteLLM hands a callback its OWN normalised object, not the upstream body:
# an OpenAI Responses reply arrives as prompt_tokens / prompt_tokens_details.
# Asserting the wire shape here is what proved the shipped adapter read keys
# that are never present.
assert native["prompt_tokens_details"]["cached_tokens"] == 7_000
assert native["prompt_tokens_details"]["cache_write_tokens"] == 1_000
assert event["provider"] == LITELLM_NORMALIZED
assert event["call_type"] == "anthropic_messages", "the observed call type, not a Responses one"
usage = normalize_usage(event["provider"], native)
assert usage.total_input_tokens == 10_000
assert usage.cache_read_input_tokens == 7_000
assert usage.cache_write_input_tokens == 1_000
assert usage.ordinary_input_tokens == 2_000
assert usage.complete, "a run that cannot interpret its own usage measured nothing"
def test_probe_what_identity_the_real_cli_actually_sends(tmp_path: Path) -> None:
"""An experiment, not an assertion: which fields could correlate a request to a cell?
Per-cell usage attribution is unbuilt because one proxy serves the whole
sweep, so anything read from the proxy environment is identical for every
request. Attribution needs something that travels WITH the request, and
what the Claude Code CLI actually sends is not documented anywhere I can
check - guessing it is how the last three accounting bugs happened.
So this drives the REAL pinned CLI against the mock and prints the
identity-bearing fields that arrive. It asserts only that a request was
made; the value is the recorded evidence, which the job log preserves.
"""
claude = os.environ.get("CLAUDE_CANARY_BIN")
if not claude or not Path(claude).exists():
pytest.skip("no pinned Claude CLI here; the containment job supplies CLAUDE_CANARY_BIN")
with MockProvider(default=Reply(text="ok")) as provider:
subprocess.run(
[claude, "-p", "--input-format", "text", "--output-format", "stream-json", "--verbose"],
input=b"say ok",
capture_output=True,
timeout=120,
env={
**os.environ,
"ANTHROPIC_BASE_URL": provider.base_url,
"ANTHROPIC_API_KEY": "offline-probe",
"HOME": str(tmp_path),
},
)
assert provider.requests, "the real CLI never reached the mock provider"
request = provider.requests[0]
interesting = {
"header:" + name: value
for name, value in request.headers.items()
if any(k in name.lower() for k in ("session", "user", "trace", "request-id", "conversation", "metadata"))
}
interesting.update(
{f"body:{key}": request.body[key] for key in ("metadata", "user", "session_id") if key in request.body}
)
print("\nIDENTITY FIELDS THE REAL CLI SENDS:")
print(" body keys:", sorted(request.body))
print(" candidate correlators:", interesting or "NONE — per-cell attribution needs another mechanism")
def test_scripted_tools_survive_the_responses_protocol_too() -> None:
"""The gateway uses Responses BECAUSE it carries tool use.
Emitting only output_text there meant a scripted Write or Skill crossed the
gateway with the tool dropped, so a mock claiming to serve both protocols
was wrong about the one the gateway actually runs.
"""
write = {"name": "Write", "input": {"file_path": "/review-output/review-output.json", "content": "{}"}}
with MockProvider([Reply(text="writing", tools=[write])]) as provider:
_status, raw = _post(provider.base_url + "/v1/responses", {"model": "m", "input": []})
output = json.loads(raw)["output"]
calls = [item for item in output if item["type"] == "function_call"]
assert len(calls) == 1, "the scripted tool must cross the Responses path"
assert calls[0]["name"] == "Write"
assert json.loads(calls[0]["arguments"])["file_path"] == "/review-output/review-output.json"
def test_an_omitted_cache_field_stays_omitted_on_the_responses_wire_too() -> None:
"""Absence must survive both protocols, not just the Anthropic one.
`_int_or_none` reads an absent detail key as unknown and a present 0 as a
measured zero, so serializing 0 for a scripted None would claim a
measurement the reply never made.
"""
with MockProvider([Reply(input_tokens=2_000, cache_read_input_tokens=None)]) as provider:
_status, raw = _post(provider.base_url + "/v1/responses", {"model": "m", "input": []})
details = json.loads(raw)["usage"]["input_tokens_details"]
assert "cached_tokens" not in details, "an omitted field must not serialize as a measured zero"
assert details["cache_write_tokens"] == 0, "a scripted 0 is still a real measurement"

View file

@ -1,435 +0,0 @@
"""Credential routing for the OpenAI loopback gateway."""
from __future__ import annotations
import subprocess
import os
import json
import signal
import socket
import time
import sys
import threading
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest import mock
import pytest
import yaml
from workflow_bench.model_gateway import (
DEFAULT_GATEWAY_READY_TIMEOUT_S,
GATEWAY_READY_TIMEOUT_ENV,
GATEWAY_REQUEST_TIMEOUT_S,
OpenAIGateway,
gateway_ready_timeout_s,
anthropic_api_key_from_environ,
claude_gateway_model_env,
is_openai_model,
litellm_proxy_argv,
openai_backend_model,
openai_litellm_config,
resolve_model_access,
write_openai_litellm_config,
)
def test_supervisor_reports_proxy_failure_without_aborting_on_its_stdin_reader():
supervisor = Path(__file__).resolve().parents[1] / "workflow_bench" / "gateway_supervisor.py"
process = subprocess.Popen(
[sys.executable, str(supervisor), sys.executable, "-c", "raise RuntimeError('proxy failed')"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
try:
# Keep the owner pipe open: the proxy exits independently of its owner.
process.wait(timeout=10)
assert process.returncode == 1
assert b"proxy failed" in process.stderr.read()
finally:
process.stdin.close()
if process.poll() is None:
process.kill()
process.wait(timeout=5)
def test_locked_litellm_translates_messages_to_offline_responses(monkeypatch, tmp_path):
from workflow_bench import model_gateway
observed = []
class Upstream(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
observed.append((self.path, self.headers.get("Authorization"), body))
response = {
"id": "resp_offline",
"object": "response",
"created_at": int(time.time()),
"status": "completed",
"model": "gpt-4.1",
"error": None,
"output": [
{
"id": "msg_offline",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "offline pong", "annotations": []}],
}
],
"usage": {
"input_tokens": 1,
"output_tokens": 2,
"total_tokens": 3,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0},
},
}
payload = json.dumps(response).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
upstream = ThreadingHTTPServer(("127.0.0.1", 0), Upstream)
worker = threading.Thread(target=upstream.serve_forever, daemon=True)
worker.start()
original = model_gateway.write_openai_litellm_config
def config(path, names):
original(path, names)
document = yaml.safe_load(path.read_text())
for entry in document["model_list"]:
entry["litellm_params"]["api_base"] = f"http://127.0.0.1:{upstream.server_port}/v1"
path.write_text(yaml.safe_dump(document))
return path
monkeypatch.setattr(model_gateway, "write_openai_litellm_config", config)
try:
with OpenAIGateway(
openai_api_key="offline-upstream-secret",
model_names=["gpt-4.1"],
work_dir=tmp_path / "gateway",
ready_timeout_s=60,
) as gateway:
port = gateway.port
request = urllib.request.Request(
gateway.base_url + "/v1/messages",
data=json.dumps(
{
"model": "gpt-4.1",
"max_tokens": 32,
"messages": [{"role": "user", "content": "ping"}],
}
).encode(),
headers={
"Content-Type": "application/json",
"x-api-key": gateway.auth_token,
"anthropic-version": "2023-06-01",
},
)
with urllib.request.urlopen(request, timeout=30) as response:
translated = json.load(response)
assert "offline pong" in json.dumps(translated)
assert len(observed) == 1 and observed[0][0] == "/v1/responses"
assert observed[0][1] == "Bearer offline-upstream-secret"
assert gateway.auth_token != "offline-upstream-secret"
with socket.socket() as client:
assert client.connect_ex(("127.0.0.1", port)) != 0
gateway.close() # ownership close is idempotent
finally:
upstream.shutdown()
upstream.server_close()
worker.join(timeout=5)
@pytest.mark.parametrize("termination", ["terminate", "kill"])
@pytest.mark.parametrize("phase", ["ready", "startup"])
def test_gateway_lifetime_ends_with_its_parent(tmp_path, termination, phase):
ready = tmp_path / "ready.json"
proxy_pid = tmp_path / "proxy-pid"
proxy = tmp_path / "proxy.py"
proxy.write_text(f"""import os,sys
from pathlib import Path
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response({200 if phase == "ready" else 503}); self.end_headers()
Path(sys.argv[2]).write_text(str(os.getpid()))
HTTPServer(('127.0.0.1', int(sys.argv[1])), Handler).serve_forever()
""")
parent_code = f"""
import json,os,sys,time,subprocess
from pathlib import Path
from workflow_bench import model_gateway
model_gateway.litellm_proxy_argv=lambda **kwargs: [sys.executable, {str(proxy)!r}, str(kwargs['port']), {str(proxy_pid)!r}]
gateway=model_gateway.OpenAIGateway(openai_api_key='offline-secret', model_names=['gpt-4.1'], work_dir=Path({str(tmp_path / "gateway")!r}), ready_timeout_s=10)
if {phase == "startup"!r}:
Path({str(ready)!r}).write_text(json.dumps({{'port':gateway.port}}))
gateway.__enter__()
if gateway._process.stdin is not None:
assert not os.get_inheritable(gateway._process.stdin.fileno())
Path({str(ready)!r}).write_text(json.dumps({{'port':gateway.port}}))
time.sleep(60)
"""
parent = subprocess.Popen(
[sys.executable, "-c", parent_code],
cwd=Path(__file__).resolve().parents[1],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
try:
deadline = time.monotonic() + 12
while (not ready.exists() or not proxy_pid.exists()) and parent.poll() is None and time.monotonic() < deadline:
time.sleep(0.02)
if not ready.exists():
_, stderr = parent.communicate(timeout=1)
pytest.fail(stderr)
port = json.loads(ready.read_text())["port"]
getattr(parent, termination)()
parent.wait(timeout=5)
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
with socket.socket() as client:
if client.connect_ex(("127.0.0.1", port)) != 0:
break
time.sleep(0.02)
else:
pytest.fail("gateway port survived abrupt parent death")
finally:
if parent.poll() is None:
parent.kill()
parent.wait(timeout=5)
if proxy_pid.exists():
try:
if os.name == "nt":
os.kill(int(proxy_pid.read_text()), signal.SIGTERM)
else:
os.killpg(int(proxy_pid.read_text()), signal.SIGKILL)
except OSError:
# Successful ownership cleanup has already removed this process.
pass
@pytest.mark.parametrize(
("model", "expected"),
[
("gpt-4.1", True),
("gpt-4o-mini", True),
("openai/gpt-4.1", True),
("o3", True),
("o4-mini", True),
("claude-sonnet-5", False),
("free-coder", False),
("pinned-model", False),
],
)
def test_is_openai_model(model: str, expected: bool) -> None:
assert is_openai_model(model) is expected
def test_openai_litellm_config_routes_each_id_to_openai_and_env_key(tmp_path: Path) -> None:
config = openai_litellm_config(["gpt-4.1", "openai/gpt-4.1-mini", "gpt-4.1"])
assert [row["model_name"] for row in config["model_list"]] == ["gpt-4.1", "openai/gpt-4.1-mini"]
assert config["model_list"][0]["litellm_params"]["model"] == "openai/gpt-4.1"
assert config["model_list"][1]["litellm_params"]["model"] == "openai/gpt-4.1-mini"
assert all(row["litellm_params"]["api_key"] == "os.environ/OPENAI_API_KEY" for row in config["model_list"])
assert all(row["model_info"] == {"mode": "responses"} for row in config["model_list"])
assert all(row["litellm_params"]["timeout"] == GATEWAY_REQUEST_TIMEOUT_S for row in config["model_list"])
assert config["litellm_settings"]["request_timeout"] == GATEWAY_REQUEST_TIMEOUT_S
path = write_openai_litellm_config(tmp_path / "litellm.yaml", ["gpt-4.1"])
assert yaml.safe_load(path.read_text())["model_list"][0]["model_name"] == "gpt-4.1"
if os.name != "nt":
# Windows chmod exposes a read-only flag, not POSIX access bits.
assert path.stat().st_mode & 0o777 == 0o600
def test_resolve_model_access_starts_proxy_only_for_openai_ids() -> None:
openai = resolve_model_access(
auth_token=None,
openai_api_key="sk-openai",
base_url=None,
models=["gpt-4.1", "gpt-4.1"],
)
assert openai.start_proxy is True
assert openai.openai_api_key == "sk-openai"
anthropic = resolve_model_access(
auth_token="sk-ant",
openai_api_key="sk-openai",
base_url=None,
models=["claude-sonnet-5"],
)
assert anthropic.start_proxy is False
existing = resolve_model_access(
auth_token="proxy-master",
openai_api_key=None,
base_url="http://127.0.0.1:4000",
models=["free-coder"],
)
assert existing.start_proxy is False
def test_resolve_model_access_rejects_openai_ids_without_a_key_and_mixed_providers() -> None:
with pytest.raises(ValueError, match="GITNEXUS_BENCH_OPENAI_API_KEY"):
resolve_model_access(
auth_token="sk-ant",
openai_api_key=None,
base_url=None,
models=["gpt-4.1"],
)
with pytest.raises(ValueError, match="mix"):
resolve_model_access(
auth_token=None,
openai_api_key="sk-openai",
base_url=None,
models=["gpt-4.1", "claude-sonnet-5"],
)
with pytest.raises(ValueError, match="--base-url"):
resolve_model_access(
auth_token=None,
openai_api_key=None,
base_url="http://127.0.0.1:4000",
models=["free-coder"],
)
def test_claude_gateway_aliases_pin_every_internal_tier_to_the_session_model() -> None:
env = claude_gateway_model_env("gpt-4.1")
assert env["ANTHROPIC_MODEL"] == "gpt-4.1"
assert env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] == "gpt-4.1"
assert env["CLAUDE_CODE_SUBAGENT_MODEL"] == "gpt-4.1"
# High-effort reasoning outlives Claude Code's default client timeout.
assert env["API_TIMEOUT_MS"] == str(GATEWAY_REQUEST_TIMEOUT_S * 1000)
def test_openai_gateway_never_leaves_proxy_output_on_an_undrained_pipe(tmp_path: Path) -> None:
# Nothing reads the proxy's output after startup, so a pipe would block the
# proxy once its request logs filled the buffer and hang every session.
gateway = OpenAIGateway(
openai_api_key="sk-openai-secret",
model_names=["gpt-4.1"],
work_dir=tmp_path / "gw",
ready_timeout_s=0.1,
)
captured: dict[str, object] = {}
def fake_popen(argv, **kwargs):
captured.update(kwargs)
raise OSError("no proxy in this test")
with mock.patch.object(subprocess, "Popen", fake_popen):
with pytest.raises(RuntimeError, match="failed to start the OpenAI LiteLLM gateway"):
gateway.__enter__()
assert captured["stderr"] is subprocess.STDOUT
assert captured["stdout"] is not subprocess.PIPE
assert getattr(captured["stdout"], "name", "") == str(gateway.log_path)
if os.name != "nt":
assert gateway.log_path.stat().st_mode & 0o777 == 0o600
def test_gateway_startup_budget_outlives_a_cold_litellm_import(monkeypatch, tmp_path: Path) -> None:
# Importing LiteLLM takes ~17s on a cold container filesystem and the proxy
# binds its port only afterwards, so a sub-20s budget fails as "connection
# refused" on a proxy that was merely still starting.
monkeypatch.delenv(GATEWAY_READY_TIMEOUT_ENV, raising=False)
assert DEFAULT_GATEWAY_READY_TIMEOUT_S >= 60
assert gateway_ready_timeout_s() == DEFAULT_GATEWAY_READY_TIMEOUT_S
assert (
OpenAIGateway(
openai_api_key="sk-openai-secret",
model_names=["gpt-4.1"],
work_dir=tmp_path / "gw",
).ready_timeout_s
== DEFAULT_GATEWAY_READY_TIMEOUT_S
)
monkeypatch.setenv(GATEWAY_READY_TIMEOUT_ENV, "42.5")
assert gateway_ready_timeout_s() == 42.5
for bad in ("0", "-1", "soon", "nan", "inf", "-inf", "1e999"):
monkeypatch.setenv(GATEWAY_READY_TIMEOUT_ENV, bad)
with pytest.raises(ValueError, match=GATEWAY_READY_TIMEOUT_ENV):
gateway_ready_timeout_s()
@pytest.mark.parametrize("timeout", [0.0, -1.0, float("nan"), float("inf"), float("-inf")])
def test_gateway_rejects_invalid_explicit_readiness_budgets(tmp_path: Path, timeout: float) -> None:
with pytest.raises(ValueError, match="finite and positive"):
OpenAIGateway(
openai_api_key="sk-offline-test",
model_names=["gpt-4.1"],
work_dir=tmp_path / "gw",
ready_timeout_s=timeout,
)
def test_gateway_readiness_timeout_reports_the_proxy_log_and_the_override(tmp_path: Path) -> None:
gateway = OpenAIGateway(
openai_api_key="sk-openai-secret",
model_names=["gpt-4.1"],
work_dir=tmp_path / "gw",
ready_timeout_s=0.1,
)
gateway.work_dir.mkdir(parents=True)
gateway.log_path.write_text("ImportError: litellm proxy extras missing")
with pytest.raises(RuntimeError) as excinfo:
gateway._wait_until_ready()
message = str(excinfo.value)
assert "ImportError: litellm proxy extras missing" in message
assert GATEWAY_READY_TIMEOUT_ENV in message
def test_openai_backend_model_preserves_openai_prefix() -> None:
assert openai_backend_model("gpt-4.1") == "openai/gpt-4.1"
assert openai_backend_model("openai/gpt-4.1") == "openai/gpt-4.1"
def test_litellm_proxy_argv_uses_console_script_not_python_module(tmp_path: Path, monkeypatch) -> None:
# litellm 1.87 ships a console script and no litellm.__main__, so
# `python -m litellm` dies before the health check. Pin the supported argv.
# Under `uv run`, sys.executable is the base CPython — the script lives in
# VIRTUAL_ENV/bin instead.
python = tmp_path / "base" / "python"
venv_bin = tmp_path / "venv" / "bin"
python.parent.mkdir(parents=True)
venv_bin.mkdir(parents=True)
litellm = venv_bin / "litellm"
python.write_text("#!/bin/sh\n")
litellm.write_text("#!/bin/sh\n")
python.chmod(0o755)
litellm.chmod(0o755)
monkeypatch.setenv("VIRTUAL_ENV", str(tmp_path / "venv"))
monkeypatch.delenv("PATH", raising=False)
config = tmp_path / "litellm.yaml"
config.write_text("model_list: []\n")
argv = litellm_proxy_argv(
config=config,
host="127.0.0.1",
port=4010,
python_executable=str(python),
)
assert argv[0] == str(litellm.resolve())
assert "-m" not in argv
assert argv[1:] == ["--config", str(config), "--host", "127.0.0.1", "--port", "4010"]
def test_anthropic_api_key_prefers_the_named_env_and_keeps_the_legacy_alias(monkeypatch) -> None:
monkeypatch.delenv("GITNEXUS_BENCH_ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("GITNEXUS_BENCH_AUTH_TOKEN", "legacy-secret")
assert anthropic_api_key_from_environ() == "legacy-secret"
monkeypatch.setenv("GITNEXUS_BENCH_ANTHROPIC_API_KEY", "named-secret")
assert anthropic_api_key_from_environ() == "named-secret"

View file

@ -1,195 +0,0 @@
"""A session end to end with only the model faked.
The layers between the CLI and the row are where this harness has actually
shipped bugs - the artifact that could not be written, the usage that was never
recorded, the evidence that was scored from the wrong directory. Every one of
them sat below the level its tests exercised. These run the real session path
against a scripted provider, so the only thing not real is what the model says.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from workflow_bench.mock_provider import MockProvider, Reply
from workflow_bench.proposer_sandbox import (
host_workspace_write_boundary,
prepare_review_workspace,
prepare_sandbox,
)
from workflow_bench.review_scoring import REVIEW_OUTPUT, parse_review_output
from workflow_bench.runner_sessions import run_claude
FAKE_CLI = Path(__file__).parent / "fixtures" / "fake_claude.py"
REVIEW_JSON = '{"schema_version": 1, "verdict": "approve", "findings": []}'
def _session(clone: Path, provider: MockProvider, **overrides):
return run_claude(
"review the change",
clone,
claude_bin=str(FAKE_CLI),
timeout=60,
env={
"ANTHROPIC_BASE_URL": provider.base_url,
"ANTHROPIC_API_KEY": "offline",
"PATH": "/usr/bin:/bin",
},
**overrides,
)
@pytest.fixture
def clone(tmp_path: Path) -> Path:
workspace = tmp_path / "clone"
workspace.mkdir()
(workspace / "source.ts").write_text("export const answer = 42;\n")
return workspace
def test_a_session_records_the_usage_the_provider_reported(clone: Path) -> None:
"""Token counts must survive the CLI boundary, not be invented after it."""
reply = Reply(input_tokens=2_000, output_tokens=300, cache_read_input_tokens=7_000, cache_creation_input_tokens=1_000)
with MockProvider(default=reply) as provider:
record = _session(clone, provider)
assert record["ok"] is True, record.get("error_detail")
assert record["input_tokens"] == 2_000
assert record["cache_read_input_tokens"] == 7_000
assert record["cache_creation_input_tokens"] == 1_000
assert record["output_tokens"] == 300
# A measured zero would be indistinguishable from an unmeasured one.
assert record["cost_usd"] == 0.42
assert record["num_turns"] == 1
def test_a_scripted_write_produces_a_review_artifact_the_scorer_accepts(clone: Path) -> None:
"""The full artifact path: model asks, CLI writes atomically, scorer reads.
This is the operation that shipped empty for a whole run. Nothing here
fakes the write, the directory, or the parse - only the decision to write.
"""
with prepare_sandbox(
clone=clone, claude_bin=Path(sys.executable), backend="host-unsafe", preflight=False
) as sandbox:
artifact = prepare_review_workspace(sandbox, REVIEW_OUTPUT)
write = {"name": "Write", "input": {"file_path": str(artifact), "content": REVIEW_JSON}}
with MockProvider(default=Reply(text="reviewing", tools=[write])) as provider:
# Take the command configuration from the sandbox the way run_arm
# does, rather than calling run_claude bare. On host-unsafe the
# prefix is [] by construction, so this pins the WIRING, not the
# isolation - a bwrap run would carry a real prefix through here.
record = _session(
clone,
provider,
command_prefix=sandbox.command_prefix_for(),
require_pid_namespace=sandbox.require_pid_namespace,
)
assert record["ok"] is True, record.get("error_detail")
# Read inside the scope: prepare_sandbox removes the private root on exit.
verdict, findings = parse_review_output(artifact)
assert verdict == "approve"
assert findings == ()
def test_the_provider_saw_the_prompt_the_harness_meant_to_send(clone: Path) -> None:
"""A run that measures the wrong prompt measures nothing."""
with MockProvider() as provider:
_session(clone, provider)
assert provider.requests, "the session never reached the provider"
sent = provider.requests[0].body["messages"][0]["content"]
assert "review the change" in sent
def test_a_provider_failure_surfaces_as_a_failed_session_not_a_silent_pass(clone: Path) -> None:
"""An upstream 529 must not be recorded as a usable measurement."""
failing = Reply(status_code=529, error_body={"error": {"type": "overloaded_error"}})
with MockProvider(default=failing) as provider:
record = _session(clone, provider)
assert record["ok"] is False
assert record["error_kind"] is not None
def test_the_write_boundary_refuses_the_workspace_and_permits_the_artifact(clone: Path, tmp_path: Path) -> None:
"""The contract the empty-artifact run violated, on the backend available here.
A review must not change the workspace, and must still be able to write its
artifact ATOMICALLY - temp file beside the target, then rename - which is
what needs a writable parent DIRECTORY rather than a writable file. Both
halves are asserted through the real session, with the real boundary
applied, and the model scripted to attempt each one.
Scope: this is the host-unsafe boundary, which its own docstring calls
best-effort because a session that can chmod can undo it. The kernel-enforced
version is bubblewrap's --ro-bind, which needs namespaces this machine cannot
create; that half stays with the real-sandbox canary in CI.
"""
artifacts = tmp_path / "artifacts"
artifacts.mkdir()
target = artifacts / REVIEW_OUTPUT
protected = clone / "source.ts"
before = protected.read_text()
write_artifact = {"name": "Write", "input": {"file_path": str(target), "content": REVIEW_JSON}}
tamper = {"name": "Write", "input": {"file_path": str(protected), "content": "tampered"}}
# No writable= entry: the boundary only governs paths INSIDE the workspace
# (it refuses one that escapes), and the artifact directory deliberately
# lives outside it - that relocation is the fix for the empty-artifact run.
with host_workspace_write_boundary(clone):
with MockProvider(default=Reply(text="writing", tools=[write_artifact, tamper])) as provider:
record = _session(clone, provider)
assert record["ok"] is True, record.get("error_detail")
# The artifact landed, written the way the agent's Write tool does it.
verdict, _findings = parse_review_output(target)
assert verdict == "approve"
assert not list(artifacts.glob("*.tmp.*")), "the rename landed rather than a copy"
# The workspace did not move.
assert protected.read_text() == before, "the read-only workspace was modified"
def test_a_reply_missing_cache_usage_is_refused_not_zero_filled(clone: Path) -> None:
"""An omitted cache field must not arrive as a measured zero.
The parent already demands all four USAGE_FIELDS before it calls a session
measured (runner_sessions.well_formed). The stand-in used to default the
absent ones to 0, which both fabricated a complete measurement AND made
that parent guard unfirable from any offline test - it was always
satisfied. Scripting the absence is what proves the guard still fires.
"""
partial = Reply(input_tokens=2_000, output_tokens=300, cache_read_input_tokens=None)
with MockProvider(default=partial) as provider:
record = _session(clone, provider)
assert record["ok"] is False, "an incomplete usage report is not a usable measurement"
assert record["error_kind"] == "session-error"
@pytest.mark.parametrize("bad", [-5, True, "1200"], ids=["negative", "boolean", "string"])
def test_a_nonsense_cache_value_is_refused_rather_than_forwarded(clone: Path, bad: object) -> None:
"""A field good enough to report is good enough to validate.
The parent's well_formed check tests only that the four keys are PRESENT,
so an unvalidated cache value would ride into a success result and be
recorded as a real measurement.
"""
reply = Reply(input_tokens=2_000, output_tokens=300)
object.__setattr__(reply, "cache_read_input_tokens", bad)
with MockProvider(default=reply) as provider:
record = _session(clone, provider)
assert record["ok"] is False, f"{bad!r} must not be recorded as a measured cache value"

View file

@ -1,348 +0,0 @@
"""A whole sweep, offline: real runner, real sessions, scripted model.
The layers between a model turn and a promotion decision had never been
exercised together. Unit tests covered each in isolation and the paid runs that
would have covered the composition kept dying, so the contracts BETWEEN them
went unverified - and that is where this harness has repeatedly shipped bugs.
This drives runner.main() the way the workflow does. Everything is real: task
selection, hidden-oracle capture, the sandbox, the CLI subprocess, artifact
capture, review scoring against the oracle, aggregation, the health guard, and
the promotion gate. Only the model is scripted, through MockProvider.
Two provisioning steps are stubbed because this environment cannot supply them,
and neither is harness logic: the pinned gitnexus runtime mounts (no
node_modules in a worktree) and the sanitized graph build (needs the gitnexus
CLI at a mounted path). Containment is host-unsafe here; bubblewrap stays with
the real-sandbox canary in the containment job.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import os
import shutil
import pytest
from workflow_bench import oracle_assets, runner
from workflow_bench.mock_provider import MockProvider, Reply
FAKE_CLI = Path(__file__).parent / "fixtures" / "fake_claude.py"
ARMS = ("ce_review", "review", "candidate_review")
# When set, the sweep runs with NOTHING provisioning-stubbed: real bubblewrap
# containment, the real pinned runtime mounts, and the real sanitized graph
# build. The named CI job installs all three, so a missing one there is a
# regression rather than an unsupported machine - it FAILS instead of quietly
# degrading to the stubbed path, which is the whole point of the gate.
FULL_SWEEP_ENV = "GITNEXUS_REQUIRE_FULL_SWEEP"
FULL_SWEEP = os.environ.get(FULL_SWEEP_ENV) == "1"
# The runner refuses --unsafe-no-bwrap whenever CI is set, because that mode runs
# sessions with bypassPermissions behind a boundary its own docstring calls "not
# a security boundary". Deleting CI to get past that refusal would run an
# uncontained agent sweep on the runner holding the checkout and credentials, so
# the stubbed path is skipped under CI instead. The containment job sets
# GITNEXUS_REQUIRE_FULL_SWEEP=1 and takes the real bubblewrap path, so CI keeps
# its coverage; only the uncontained convenience run is given up.
pytestmark = pytest.mark.skipif(
not FULL_SWEEP and bool(os.environ.get("CI")),
reason="an uncontained sweep must not run in CI; the containment job runs it with GITNEXUS_REQUIRE_FULL_SWEEP=1",
)
# The review output and the hidden labels are DELIBERATELY different shapes -
# the labels carry line_start/line_end and no recommendation. Only a real run
# surfaces that; it is why these are written out rather than shared.
FINDING = {
"id": "f1", "severity": "high", "category": "correctness", "path": "src/sum.js",
"line": 1, "end_line": 1, "blocking": True, "scenario": "review-defect",
"evidence": "export const total = (a, b) => a - b;", "recommendation": "use a + b",
}
LABEL = {"id": "f1", "severity": "high", "category": "correctness",
"path": "src/sum.js", "line_start": 1, "line_end": 1}
SECOND_LABEL = {"id": "f2", "severity": "high", "category": "correctness",
"path": "src/scale.js", "line_start": 1, "line_end": 1}
SECOND_FINDING = {
"id": "f2", "severity": "high", "category": "correctness", "path": "src/scale.js",
"line": 1, "end_line": 1, "blocking": True, "scenario": "review-defect",
"evidence": "export const twice = (n) => n + 2;", "recommendation": "use n * 2",
}
def _git(repo: Path, *args: str) -> str:
return subprocess.run(["git", "-C", str(repo), *args], check=True,
capture_output=True, text=True).stdout.strip()
@pytest.fixture
def bench(tmp_path: Path):
"""A self-contained corpus: one repo, one task, one hidden label."""
repo = tmp_path / "repo"
(repo / "src").mkdir(parents=True)
(repo / "src" / "sum.js").write_text("export const total = (a, b) => a - b;\n")
(repo / "src" / "scale.js").write_text("export const twice = (n) => n + 2;\n")
_git(repo, "init", "-q", ".")
_git(repo, "config", "user.email", "t@t")
_git(repo, "config", "user.name", "t")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "fixture")
sha = _git(repo, "rev-parse", "HEAD")
oracles = tmp_path / "oracles"
oracles.mkdir()
(oracles / "review-fixture-defect.labels.json").write_text(
json.dumps({"schema_version": 1, "findings": [LABEL]})
)
(oracles / "review-fixture-second.labels.json").write_text(
json.dumps({"schema_version": 1, "findings": [SECOND_LABEL]})
)
tasks = tmp_path / "tasks.yaml"
tasks.write_text(
"tasks:\n"
" - id: review-fixture-defect\n"
" class: review-defect\n"
f" repo: {repo}\n"
f" ref: {sha}\n"
" prompt: Review this change and report actionable defects.\n"
' verify: test -s "$GITNEXUS_BENCH_REVIEW_OUTPUT"\n'
" oracle:\n"
' command: test -s "$GITNEXUS_BENCH_REVIEW_OUTPUT"\n'
" files: [{ source: review-fixture-defect.labels.json, target: review-labels.json }]\n"
# A SECOND task, because the thing a cross-task scheduler changes is
# invisible with one: waves are per-task, so a single task cannot show
# ordering, packing, or a breaker that spans a task boundary.
" - id: review-fixture-second\n"
" class: review-defect\n"
f" repo: {repo}\n"
f" ref: {sha}\n"
" prompt: Review the scaling helper and report actionable defects.\n"
' verify: test -s "$GITNEXUS_BENCH_REVIEW_OUTPUT"\n'
" oracle:\n"
' command: test -s "$GITNEXUS_BENCH_REVIEW_OUTPUT"\n'
" files: [{ source: review-fixture-second.labels.json, target: review-labels.json }]\n"
)
plugin = tmp_path / "ce-plugin"
(plugin / ".claude-plugin").mkdir(parents=True)
(plugin / ".claude-plugin" / "plugin.json").write_text(
json.dumps({"name": "compound-engineering", "version": "0.0.0-fixture"})
)
for skill in ("ce-plan", "ce-work", "ce-code-review"):
directory = plugin / "skills" / skill
directory.mkdir(parents=True)
(directory / "SKILL.md").write_text(f"---\nname: {skill}\ndescription: fixture\n---\nFixture.\n")
overlay = tmp_path / "overlay" / ".claude" / "skills" / "gitnexus-review"
overlay.mkdir(parents=True)
(overlay / "SKILL.md").write_text("---\nname: gitnexus-review\ndescription: fixture\n---\nCandidate.\n")
return SimpleNamespace(tasks=tasks, oracles=oracles, plugin=plugin,
overlay=tmp_path / "overlay", out=tmp_path / "out")
def _stub_provisioning(monkeypatch: pytest.MonkeyPatch) -> None:
"""Replace what this machine cannot supply - and nothing else.
Under FULL_SWEEP nothing is replaced: the runtime mounts and the graph are
built for real, so the sweep exercises containment and provisioning too.
"""
if FULL_SWEEP:
if shutil.which("bwrap") is None:
pytest.fail(f"{FULL_SWEEP_ENV}=1 but bubblewrap is absent")
return
monkeypatch.setattr(runner, "trusted_gitnexus_runtime_mounts", lambda: ())
def materialize(worktree, *, sanitized_head=None, **_kwargs):
# The one-clone registry guard reads this before any session runs.
meta = Path(worktree) / ".gitnexus"
meta.mkdir(parents=True, exist_ok=True)
(meta / "meta.json").write_text(
json.dumps({"indexedAt": "2026-09-08T00:00:00Z", "lastCommit": sanitized_head or "0" * 40})
)
def fake_graph(**kwargs):
kwargs["env"].graph_snapshots[kwargs["graph_key"]] = SimpleNamespace(
digest="fixture-graph", manifest_digest="fixture-graph-manifest",
dependency_content_digest=None, dependency_manifest_digest=None,
materialize=materialize,
)
monkeypatch.setattr(runner, "ensure_task_graph", fake_graph)
def _sweep(bench, monkeypatch: pytest.MonkeyPatch, findings: list[dict], verdict: str, *, invoke_skill: bool = True):
"""Run the real CLI against a model scripted to return `findings`."""
_stub_provisioning(monkeypatch)
monkeypatch.setattr(
oracle_assets, "ORACLE_ROOT", bench.oracles, raising=False
)
monkeypatch.setattr(
runner, "capture_task_oracles",
lambda tasks, root=bench.oracles: oracle_assets.capture_task_oracles(tasks, root=root),
)
def review_for(body: str) -> str:
# Per task: the second task's defect is in another file, so replying
# with the first task's finding would score it wrong. A cross-task
# scheduler makes which task a request belongs to load-bearing.
chosen = findings
if findings and "scaling helper" in body:
chosen = [SECOND_FINDING if f is FINDING else f for f in findings]
return json.dumps({"schema_version": 1, "verdict": verdict, "findings": chosen})
class Scripted(MockProvider):
def next_reply(self) -> Reply:
body = json.dumps(self.requests[-1].body if self.requests else {})
target = re.search(r"(/[^\s\"']*review-output\.json)", body)
skill = re.search(r"\b(gitnexus-review|ce-code-review)\b", body)
return Reply(
text="reviewing",
tools=[
# The evidence gate needs a Skill request with a non-error
# result: a review that never invoked its skill measured the
# model, not the skill.
*([{"name": "Skill", "input": {"skill": skill.group(1) if skill else "gitnexus-review"}}]
if invoke_skill else []),
{"name": "Write", "input": {
"file_path": target.group(1) if target else str(bench.out / "unmatched-review-output.json"),
"content": review_for(body)}},
],
input_tokens=2_000, output_tokens=300,
cache_read_input_tokens=7_000, cache_creation_input_tokens=1_000,
)
with Scripted() as provider:
monkeypatch.setattr(sys, "argv", [
"runner", "--tasks", str(bench.tasks), "--arms", *ARMS,
"--runs", "1", "--workers", "1", "--out", str(bench.out),
"--base-url", provider.base_url, "--anthropic-api-key", "offline",
"--claude-bin", str(FAKE_CLI),
*([] if FULL_SWEEP else ["--unsafe-no-bwrap"]),
"--model", "mock-model",
"--ce-plugin-dir", str(bench.plugin), "--ce-plugin-version", "0.0.0-fixture",
"--candidate-overlay", str(bench.overlay),
])
try:
code = runner.main()
except SystemExit as exc:
code = exc.code
rows = [json.loads(line) for line in (bench.out / "results.jsonl").read_text().splitlines()]
return code, rows, provider
def _row(rows: list[dict], arm: str, task: str = "review-fixture-defect") -> dict:
return next(r for r in rows if r["arm"] == arm and r["task"] == task)
def test_a_correct_review_scores_and_the_sweep_exits_clean(bench, monkeypatch) -> None:
"""The whole path, green: every arm measured, scored, and accounted for."""
code, rows, provider = _sweep(bench, monkeypatch, [FINDING], "request_changes")
assert code in (None, 0), f"sweep did not succeed: {code}"
tasks = {"review-fixture-defect", "review-fixture-second"}
assert len(rows) == len(ARMS) * len(tasks)
assert len(provider.requests) == len(ARMS) * len(tasks), "each cell must reach the provider once"
assert {r["task"] for r in rows} == tasks, "both tasks must have run"
row = _row(rows, "review")
assert row["ok"] is True and row["resolved"] is True
assert row["skill_invoked"] is True
assert (row["review_true_positives"], row["review_false_positives"], row["review_false_negatives"]) == (1, 0, 0)
assert row["review_f1"] == 1.0
# The provider's own numbers survived the CLI, the parser and the row.
assert row["cache_read_input_tokens"] == 7_000
assert row["input_tokens"] == 2_000
# Each task scored against ITS OWN oracle. This is what a cross-task
# scheduler puts at risk: interleaving cells from different tasks means a
# mis-routed context or artifact scores one task against another's labels,
# and both would still look "green" per row.
second = _row(rows, "review", task="review-fixture-second")
assert second["resolved"] is True and second["review_f1"] == 1.0
assert second["review_artifact"] == "review-fixture-second-review-run0.review.json"
for name in ("results.jsonl", "report.md", "promotion.json"):
assert (bench.out / name).is_file(), f"{name} was not written"
assert (bench.out / "review-fixture-defect-review-run0.review.json").is_file()
def test_one_run_cannot_promote_a_candidate(bench, monkeypatch) -> None:
"""The gate refuses on insufficient paired runs, and says so."""
_sweep(bench, monkeypatch, [FINDING], "request_changes")
promotion = json.loads((bench.out / "promotion.json").read_text())
assert promotion["run_status"] == "complete"
decision = next(d for d in promotion["decisions"] if d["candidate_arm"] == "candidate_review")
assert decision["decision"] == "insufficient_evidence"
assert any("valid paired runs" in reason for reason in decision["reasons"])
def test_a_finding_in_the_wrong_place_scores_zero_but_stays_valid_evidence(bench, monkeypatch) -> None:
"""Being wrong is a quality result, not a broken measurement.
The negative control that makes the passing case mean something: same
harness, same well-formed artifact, only the answer changed.
"""
wrong = {**FINDING, "path": "src/WRONG.js", "line": 99, "end_line": 99}
_code, rows, _provider = _sweep(bench, monkeypatch, [wrong], "request_changes")
row = _row(rows, "review")
assert (row["review_true_positives"], row["review_false_positives"], row["review_false_negatives"]) == (0, 1, 1)
assert row["review_f1"] == 0.0
assert row["resolved"] is False
assert row["error_kind"] == "oracle-failed", "a wrong answer is not a session or evidence failure"
assert row["review_evidence_valid"] is True, "the artifact was well formed; only the answer was wrong"
def test_approving_defective_code_is_a_miss_with_no_false_positive(bench, monkeypatch) -> None:
"""The other half of the control: silence scores differently from a wrong guess."""
_code, rows, _provider = _sweep(bench, monkeypatch, [], "approve")
row = _row(rows, "review")
assert (row["review_true_positives"], row["review_false_positives"], row["review_false_negatives"]) == (0, 0, 1)
assert row["review_precision"] is None, "precision is undefined with no predictions, not zero"
assert row["review_verdict_correct"] is False, "approving defective code is the wrong verdict"
assert row["review_evidence_valid"] is True
def test_a_review_that_never_invoked_its_skill_is_not_a_measurement(bench, monkeypatch) -> None:
"""The gate that separates measuring a SKILL from measuring a model.
Added because a mutation exposed it: forcing skill_was_invoked_events to
return True left every other test here passing, so nothing pinned the gate.
The artifact is written and correct in this run - only the skill request is
missing - so a pass would mean the arm scored a review it never performed.
"""
code, rows, _provider = _sweep(bench, monkeypatch, [FINDING], "request_changes", invoke_skill=False)
row = _row(rows, "review")
assert row["skill_invoked"] is False
assert row["error_kind"] == "skill-not-invoked"
assert code not in (None, 0), "the sweep must not report success on unusable evidence"
# The row still carries its own score - the artifact was well formed - and
# aggregate() DOES count it in the arm's quality median (the KNOWN GAP noted
# above aggregate(); test_workflow_bench pins the resulting 0.5). Filtering
# it out of the median alone inverted a promotion, because valid_runs and
# excluded_runs kept counting it. It counts for cost either way: the session
# ran and was billed.
assert row["review_weighted_f1"] == 1.0
assert row["review_evidence_valid"] is True

View file

@ -5,9 +5,7 @@ from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import os import os
import shutil
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@ -15,13 +13,7 @@ import pytest
from workflow_bench import oracle_assets, runner from workflow_bench import oracle_assets, runner
from workflow_bench.evolution import evaluate_candidate from workflow_bench.evolution import evaluate_candidate
from workflow_bench.oracle_assets import ( from workflow_bench.oracle_assets import capture_task_oracle, staged_task_oracle
capture_task_oracle,
require_hidden_harness_absent,
review_case_setup_command,
staged_task_oracle,
with_hidden_harness_apply_exclude,
)
def oracle_task(*, command: str = "true", source: str = "oracle.test.ts") -> dict[str, object]: def oracle_task(*, command: str = "true", source: str = "oracle.test.ts") -> dict[str, object]:
@ -60,7 +52,6 @@ def bench_args() -> argparse.Namespace:
claude_bin="claude", claude_bin="claude",
timeout=5, timeout=5,
model="pinned-model", model="pinned-model",
effort="xhigh",
base_url=None, base_url=None,
auth_token=None, auth_token=None,
) )
@ -157,73 +148,6 @@ def test_clone_sanitization_prunes_harness_checkout_and_recoverable_history(tmp_
assert git(clone, "status", "--porcelain=v1", "--untracked-files=all").stdout == "" assert git(clone, "status", "--porcelain=v1", "--untracked-files=all").stdout == ""
def test_require_hidden_harness_absent_fails_closed_on_leftover_tree(tmp_path: Path) -> None:
clone = tmp_path / "clone"
hidden = clone / "eval" / "workflow_bench"
hidden.mkdir(parents=True)
(hidden / "review_cases").mkdir()
with pytest.raises(ValueError, match="hidden harness visible"):
require_hidden_harness_absent(clone)
shutil.rmtree(hidden)
require_hidden_harness_absent(clone)
def test_hidden_harness_apply_exclude_is_idempotent() -> None:
raw = "git apply eval/workflow_bench/review_cases/pr.patch && rm -rf eval/workflow_bench"
once = with_hidden_harness_apply_exclude(raw)
assert once == review_case_setup_command("pr.patch")
assert with_hidden_harness_apply_exclude(once) == once
assert with_hidden_harness_apply_exclude("true") == "true"
def test_review_setup_skips_sanitized_harness_hunks(tmp_path: Path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
def git(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
["git", "-C", str(repo), *args],
check=False,
capture_output=True,
text=True,
)
if check and result.returncode != 0:
pytest.fail(f"git {' '.join(args)} failed: {result.stderr}")
return result
git("init", "--quiet", "--initial-branch=main")
git("config", "user.name", "Review Setup")
git("config", "user.email", "review-setup.invalid")
(repo / "visible.py").write_text("old\n")
hidden = repo / "eval" / "workflow_bench"
hidden.mkdir(parents=True)
(hidden / "learnings.jsonl").write_text("{}\n")
git("add", "--all")
git("commit", "--quiet", "-m", "base with harness file")
(repo / "visible.py").write_text("new\n")
(hidden / "learnings.jsonl").write_text("{}\nextra\n")
patch = git("diff").stdout
git("checkout", "--", ".")
shutil.rmtree(hidden)
patch_path = hidden / "review_cases" / "case.patch"
patch_path.parent.mkdir(parents=True)
patch_path.write_text(patch)
rejected = git("apply", "--check", str(patch_path.relative_to(repo)), check=False)
assert rejected.returncode != 0
assert "learnings.jsonl" in rejected.stderr
setup = with_hidden_harness_apply_exclude(
"git apply eval/workflow_bench/review_cases/case.patch && rm -rf eval/workflow_bench"
)
applied = subprocess.run(["/bin/sh", "-lc", setup], cwd=repo, check=False, capture_output=True, text=True)
assert applied.returncode == 0, applied.stderr
assert (repo / "visible.py").read_text() == "new\n"
assert not hidden.exists()
def test_clone_sanitization_prunes_remote_history_when_head_never_had_harness(tmp_path: Path) -> None: def test_clone_sanitization_prunes_remote_history_when_head_never_had_harness(tmp_path: Path) -> None:
source = tmp_path / "source" source = tmp_path / "source"
source.mkdir() source.mkdir()
@ -375,26 +299,6 @@ def test_vacuous_authored_test_cannot_self_certify_resolution(
assert record["error_kind"] == "oracle-failed" assert record["error_kind"] == "oracle-failed"
def test_host_unsafe_oracle_executes_beside_candidate_and_is_removed(tmp_path: Path) -> None:
from workflow_bench.proposer_sandbox import prepare_sandbox
source = tmp_path / "oracles"
write_oracle(
source,
b"from pathlib import Path\nassert (Path(__file__).resolve().parents[2] / 'candidate.txt').read_text() == 'candidate'\n",
)
snapshot = capture_task_oracle(
oracle_task(command='python3 "$GITNEXUS_BENCH_ORACLE_ROOT/nested/oracle.test.ts"'), root=source
)
clone = tmp_path / "clone"
clone.mkdir()
(clone / "candidate.txt").write_text("candidate")
with prepare_sandbox(clone=clone, claude_bin=sys.executable, backend="host-unsafe") as session:
passed, detail = runner._run_hidden_oracle(snapshot, clone, bench_args(), session)
assert passed, detail
assert not list(clone.glob(".wfbench-oracle-*"))
def test_oracle_path_and_bytes_appear_only_after_the_model_session( def test_oracle_path_and_bytes_appear_only_after_the_model_session(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,

View file

@ -7,12 +7,7 @@ import os
import signal import signal
import sys import sys
import time import time
import threading
import subprocess
import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
import pytest import pytest
@ -28,101 +23,6 @@ from workflow_bench.process_control import (
PYTHON = sys.executable PYTHON = sys.executable
def test_cancel_before_spawn_never_starts_the_child(tmp_path):
event = threading.Event()
event.set()
sentinel = tmp_path / "spawned"
result = run_managed([PYTHON, "-c", f"open({str(sentinel)!r}, 'w').close()"], timeout=60, cancel_event=event)
assert result.state == "cancelled"
assert not sentinel.exists()
@pytest.mark.skipif(os.name == "nt", reason="POSIX signal escalation")
def test_cancel_after_spawn_kills_a_term_ignoring_child():
event = threading.Event()
started = time.monotonic()
result = run_managed(
[
PYTHON,
"-c",
"import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); print('ready',flush=True); time.sleep(60)",
],
timeout=60,
terminate_grace=0.1,
cancel_event=event,
stdout_observer=lambda chunk: event.set() if b"ready" in chunk else None,
)
assert result.state == "cancelled" and result.forced_kill
assert not result.timed_out
assert time.monotonic() - started < 5
@pytest.mark.skipif(os.name == "nt", reason="POSIX parent signals")
@pytest.mark.parametrize("signum", [signal.SIGINT, signal.SIGTERM])
def test_signal_cancels_active_wave_before_assets_are_released(tmp_path, signum):
ready = tmp_path / "child-pid"
assets = tmp_path / "assets"
assets.write_text("shared")
command = (
f"import os,time; from pathlib import Path; Path({str(ready)!r}).write_text(str(os.getpid())); time.sleep(60)"
)
script = f"""
import json,sys
from pathlib import Path
from workflow_bench.process_control import cancellation_scope, run_managed
from workflow_bench.runner import sweep_task_cells
rows=[]
def run(index, arm):
if index == 0:
return {{'resolved': True, 'error_kind': None}}
result=run_managed([sys.executable, '-c', {command!r}], timeout=60)
assert Path({str(assets)!r}).exists(), 'assets removed while a worker was active'
return {{'resolved': False, 'error_kind': result.state}}
with cancellation_scope(handle_signals=True) as event:
streak, tripped=sweep_task_cells([(i, 'review') for i in range(10)], workers=2, run=run,
on_start=lambda *args: None, on_record=lambda i,a,r: rows.append([i,r]),
outage_streak=0, outage_limit=5, cancel_event=event)
Path({str(assets)!r}).unlink()
print(json.dumps({{'rows': rows, 'stopped': event.is_set(), 'tripped': tripped}}))
"""
process = subprocess.Popen(
[PYTHON, "-c", script],
cwd=Path(__file__).resolve().parents[1],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
try:
deadline = time.monotonic() + 10
while not ready.exists() and process.poll() is None and time.monotonic() < deadline:
time.sleep(0.01)
assert ready.exists()
child_pid = int(ready.read_text())
started = time.monotonic()
process.send_signal(signum)
stdout, stderr = process.communicate(timeout=15)
assert process.returncode == 0, stderr
assert time.monotonic() - started < 15
report = json.loads(stdout)
assert report["stopped"] and not report["tripped"], "cancelled, not an outage"
assert [row[0] for row in report["rows"]] == [0, 1]
assert report["rows"][0][1]["resolved"] is True
assert report["rows"][1][1]["error_kind"] == "cancelled"
with pytest.raises(ProcessLookupError):
os.kill(child_pid, 0)
assert not assets.exists()
finally:
if process.poll() is None:
process.kill()
process.wait(timeout=5)
if ready.exists():
try:
os.killpg(int(ready.read_text()), signal.SIGKILL)
except ProcessLookupError:
# Successful cancellation has already reaped this process group.
pass
def test_managed_process_captures_normal_exit() -> None: def test_managed_process_captures_normal_exit() -> None:
result = run_managed( result = run_managed(
[PYTHON, "-c", "import sys; print('out'); print('err', file=sys.stderr)"], [PYTHON, "-c", "import sys; print('out'); print('err', file=sys.stderr)"],
@ -224,64 +124,6 @@ def test_parent_stdout_capture_reports_overflow_without_stopping_drain() -> None
assert result.stdout_tail.endswith("END") assert result.stdout_tail.endswith("END")
def test_echo_stdout_streams_child_progress_and_stays_off_by_default(capfd) -> None:
command = [PYTHON, "-c", "import os; os.write(1, b'[task][arm][run 0] starting\\n')"]
quiet = run_managed(command, timeout=5)
assert quiet.ok
assert "starting" not in capfd.readouterr().err
echoed = run_managed(command, timeout=5, echo_stdout=True)
assert echoed.ok
captured = capfd.readouterr()
assert "[task][arm][run 0] starting" in captured.err
# Echoing is a passthrough, not a redirect: the tail stays intact for the
# caller that reports it after the process ends.
assert "starting" in echoed.stdout_tail
def test_echo_reaches_the_log_while_the_child_is_still_running(tmp_path: Path, monkeypatch) -> None:
"""Streaming has to be prompt, not merely eventual.
A sweep emits one line every ~45 minutes. Draining with `read(8192)` still
delivers every byte, so the tail and the capture look correct — but nothing
surfaces until the pipe closes, which turns a 15-hour job into a silent one
and is the whole reason this passthrough exists.
The child here refuses to exit until the echoed line has been observed, so
an implementation that only flushes at EOF deadlocks and fails on the
timeout rather than passing on a technicality.
"""
released = tmp_path / "echo-observed"
class Sink:
def write(self, data: bytes) -> int:
if b"first-line" in data:
released.write_text("go")
return len(data)
def flush(self) -> None:
pass
monkeypatch.setattr(process_control.sys, "stderr", SimpleNamespace(buffer=Sink()))
script = """
import pathlib, sys, time
sys.stdout.write('first-line\\n')
sys.stdout.flush()
target = pathlib.Path(%r)
for _ in range(400):
if target.exists():
break
time.sleep(0.05)
""" % str(released)
result = run_managed([PYTHON, "-c", script], timeout=15, echo_stdout=True)
assert released.exists(), "the line never reached the echo sink while the child ran"
assert result.ok
assert "first-line" in result.stdout_tail
def test_incomplete_stdin_delivery_cannot_report_success() -> None: def test_incomplete_stdin_delivery_cannot_report_success() -> None:
result = run_managed( result = run_managed(
[PYTHON, "-c", "import os,time; os.close(0); time.sleep(0.05)"], [PYTHON, "-c", "import os,time; os.close(0); time.sleep(0.05)"],
@ -611,57 +453,3 @@ def test_windows_normal_parent_with_grandchild_is_not_successful_evidence(tmp_pa
assert result.forced_kill assert result.forced_kill
assert not result.ok assert not result.ok
assert not sentinel.exists() assert not sentinel.exists()
@pytest.mark.skipif(os.name == "nt", reason="POSIX process-group ownership canary")
def test_concurrent_cells_reap_only_their_own_process_tree(tmp_path: Path) -> None:
"""One cell timing out must not touch a sibling cell running beside it.
`run_managed` reaps by process group. Cells only ever ran one at a time
before, so nothing exercised what happens when a `killpg` fires while other
owned trees are alive — a leaked or shared pgid would take the siblings
down with it, and the sweep would read that as two more excluded runs.
"""
survivor_sentinel = tmp_path / "survivor-finished"
victim_sentinel = tmp_path / "victim-escaped"
# Each cell spawns a descendant, like a sandboxed session does.
survivor = """
import pathlib, subprocess, sys, time
child = subprocess.Popen([sys.executable, '-c', "import time; time.sleep(2)"])
time.sleep(1.0)
pathlib.Path(%r).write_text('finished')
child.wait()
print('survivor-done', flush=True)
""" % str(survivor_sentinel)
victim = """
import pathlib, signal, subprocess, sys, time
signal.signal(signal.SIGTERM, signal.SIG_IGN)
subprocess.Popen([
sys.executable, '-c',
"import signal,time,pathlib; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1.5); pathlib.Path(%r).write_text('escaped')"
])
while True:
time.sleep(0.01)
""" % str(victim_sentinel)
def cell(source: str, timeout: float):
return run_managed([PYTHON, "-c", source], timeout=timeout, terminate_grace=0.1)
with ThreadPoolExecutor(max_workers=3) as pool:
futures = [
pool.submit(cell, survivor, 10.0),
pool.submit(cell, victim, 0.2),
pool.submit(cell, survivor, 10.0),
]
first, doomed, second = (future.result() for future in futures)
time.sleep(1.8)
assert doomed.state == "forced-kill"
assert not victim_sentinel.exists(), "the timed-out cell leaked a descendant"
# The siblings were mid-flight when the killpg fired.
assert first.ok and second.ok
assert "survivor-done" in first.stdout_tail
assert "survivor-done" in second.stdout_tail
assert survivor_sentinel.exists()

View file

@ -3,15 +3,11 @@
import json import json
import os import os
import stat import stat
import shlex
import subprocess
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
import pytest import pytest
import yaml
from workflow_bench import evolve, promotion_apply from workflow_bench import evolve, promotion_apply
from workflow_bench import evolution, runner
from workflow_bench.evolution import ( from workflow_bench.evolution import (
CANDIDATE_SKILLS, CANDIDATE_SKILLS,
MAX_CANDIDATE_OVERLAY_BYTES, MAX_CANDIDATE_OVERLAY_BYTES,
@ -40,120 +36,6 @@ def _git(repo: Path, *arguments: str) -> str:
) )
@pytest.mark.parametrize(
"arms", [["candidate_review"], ["candidate_workflow"], ["candidate_review", "candidate_workflow"]]
)
@pytest.mark.parametrize(
"tamper",
[
None,
"aborted",
"metric",
"verdict",
"nan",
"huge-int",
"runs",
"task",
"duplicate-task",
"exclusions",
"old-schema",
],
)
def test_produced_promotion_round_trips_to_transactional_apply(tmp_path, arms, tamper):
from tests.test_evolve import bound_task_fixture, promotion_fixture
overlay = tmp_path / "overlay"
repo = tmp_path / "repo"
for arm in arms:
skill = "gitnexus-review" if arm == "candidate_review" else "gitnexus-plan"
relative = PurePosixPath(f".claude/skills/{skill}/SKILL.md")
(overlay / relative).parent.mkdir(parents=True, exist_ok=True)
(overlay / relative).write_text("candidate")
for target in mirror_targets(relative):
(repo / target).parent.mkdir(parents=True, exist_ok=True)
(repo / target).write_text("incumbent")
frozen = tmp_path / "frozen"
digest = freeze_overlay(overlay, frozen)
bases = destination_base_digests(frozen, repo_root=repo)
paired = {}
for arm in arms:
for name, candidate in ((evolution.CANDIDATE_ARMS[arm], False), (arm, True)):
paired[name] = runner.aggregate(
[
{
"resolved": True,
"cost_usd": 0.8 if candidate else 1.0,
"review_weighted_f1": 1.0 if candidate else 0.5,
"review_blocker_recall": 1.0,
"review_false_positives": 0,
"review_verdict_correct": True,
"review_clean_control": False,
"review_clean_pass": False,
}
for _ in range(3)
]
)
policy = evolution.promotion_policy(arms)
promotion = {
**promotion_fixture(),
**evolution.promotion_evidence(
{"task-a": paired},
policy=policy,
model="bench-model",
complete=True,
),
"required_candidate_arms": arms,
"candidate_overlay_digest": digest,
"target_base_digests": bases,
"selected_tasks": [bound_task_fixture()],
}
promotion = json.loads(json.dumps(promotion))
decision = promotion["decisions"][0]
row = decision["tasks"][0]
if tamper == "aborted":
promotion["run_status"] = "aborted"
elif tamper == "metric":
decision["metric"] = "fabricated"
elif tamper == "verdict":
decision["decision"] = "keep_incumbent"
elif tamper == "nan":
row["candidate"][policy[arms[0]]["metric"]] = float("nan")
elif tamper == "huge-int":
row["candidate"][policy[arms[0]]["metric"]] = 10**1000
elif tamper == "runs":
row["candidate"]["valid_runs"] = 1
elif tamper == "task":
row["task"] = "unselected"
elif tamper == "duplicate-task":
decision["tasks"].append(dict(row))
elif tamper == "exclusions":
row["candidate"]["excluded_runs"] = 1
elif tamper == "old-schema":
promotion["schema_version"] = 5
def validate():
return evolve.validate_promotion_for_apply(
promotion,
overlay_digest=digest,
benchmark_model="bench-model",
proposer_model="proposer-model",
effort="xhigh",
selected_tasks=[bound_task_fixture()],
target_base_digests=bases,
required_candidate_arms=arms,
policy=policy,
)
if tamper:
with pytest.raises(ValueError):
validate()
assert all((repo / path).read_text() == "incumbent" for path in bases)
else:
assert all(decision["decision"] == "promote" for decision in validate())
written = apply_promoted_overlay(frozen, repo_root=repo, expected_target_bases=bases)
assert all((repo / path).read_text() == "candidate" for path in written)
def test_evolve_reexports_public_promotion_helpers(): def test_evolve_reexports_public_promotion_helpers():
assert evolve.mirror_targets is mirror_targets assert evolve.mirror_targets is mirror_targets
assert evolve.freeze_overlay is freeze_overlay assert evolve.freeze_overlay is freeze_overlay
@ -170,44 +52,6 @@ def test_mirror_targets_cover_canonical_and_shipped_copies():
] ]
def test_review_mirror_targets_include_cursor_distribution():
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-review/SKILL.md"))
assert targets == [
PurePosixPath(".claude/skills/gitnexus-review/SKILL.md"),
PurePosixPath("gitnexus/skills/gitnexus-review/SKILL.md"),
PurePosixPath("gitnexus-claude-plugin/skills/gitnexus-review/SKILL.md"),
PurePosixPath("gitnexus-cursor-integration/skills/gitnexus-review/SKILL.md"),
]
def test_workflow_stages_every_review_mirror_and_rejects_unrelated_changes(tmp_path):
overlay = tmp_path / "overlay"
relative = PurePosixPath(".claude/skills/gitnexus-review/SKILL.md")
(overlay / relative).parent.mkdir(parents=True)
(overlay / relative).write_text("candidate")
repo = tmp_path / "repo"
targets = mirror_targets(relative)
for path in targets:
(repo / path).parent.mkdir(parents=True, exist_ok=True)
(repo / path).write_text("incumbent")
(repo / "unrelated.txt").write_text("before")
_git(repo, "init", "-q")
_git(repo, "add", ".")
_git(repo, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-qm", "base")
apply_promoted_overlay(overlay, repo_root=repo)
workflow_path = Path(__file__).resolve().parents[2] / ".github/workflows/gitnexus-skill-evolution.yml"
steps = yaml.safe_load(workflow_path.read_text())["jobs"]["evolve"]["steps"]
publish = next(step["run"] for step in steps if step.get("name") == "Open the promotion PR")
staging = next(line for line in publish.splitlines() if line.startswith("git add "))
_git(repo, *shlex.split(staging)[1:])
assert _git(repo, "diff", "--cached", "--name-only").splitlines() == sorted(map(str, targets))
assert {(repo / path).read_text() for path in targets} == {"candidate"}
(repo / "unrelated.txt").write_text("after")
guard = next(step["run"] for step in steps if step.get("name") == "Detect and bound the applied promotion")
guarded = subprocess.run(["bash", "-c", guard], cwd=repo, capture_output=True, text=True)
assert guarded.returncode == 1 and "outside the skill trees: unrelated.txt" in guarded.stdout
def test_apply_promoted_overlay_writes_all_mirrors(tmp_path): def test_apply_promoted_overlay_writes_all_mirrors(tmp_path):
overlay = tmp_path / "overlay" overlay = tmp_path / "overlay"
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md" skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
@ -648,7 +492,14 @@ def test_committed_destination_bases_ignore_and_reject_live_target_edits(tmp_pat
assert dirty.read_text() == "user edit" assert dirty.read_text() == "user edit"
def test_mirror_roots_cover_every_candidate_skill_including_cursor_review(): def test_mirror_roots_cover_every_candidate_skill_and_omit_none_that_ships_to_cursor():
# promotion_apply.mirror_targets writes canonical + MIRROR_SKILL_ROOTS, which
# today omits the Cursor tree. That is only safe because no candidate skill is
# cursor-shipped. If a future edit adds a cursor-shipped skill (e.g.
# gitnexus-review) to CANDIDATE_SKILLS, apply_promoted_overlay would rewrite
# the other trees and silently skip Cursor — the PR #2488 asymmetric-sync bug
# class. Pin the invariant to the filesystem, the source of truth the TS drift
# guard already enforces.
repo_root = Path(__file__).resolve().parents[2] repo_root = Path(__file__).resolve().parents[2]
cursor_root = repo_root / "gitnexus-cursor-integration" / "skills" cursor_root = repo_root / "gitnexus-cursor-integration" / "skills"
for skill in sorted(CANDIDATE_SKILLS): for skill in sorted(CANDIDATE_SKILLS):
@ -656,9 +507,10 @@ def test_mirror_roots_cover_every_candidate_skill_including_cursor_review():
assert canonical.is_dir(), f"candidate skill {skill} has no canonical .claude/skills dir" assert canonical.is_dir(), f"candidate skill {skill} has no canonical .claude/skills dir"
for target in mirror_targets(PurePosixPath(".claude", "skills", skill, "SKILL.md")): for target in mirror_targets(PurePosixPath(".claude", "skills", skill, "SKILL.md")):
assert (repo_root / target).is_file(), f"candidate skill mirror missing on disk: {target}" assert (repo_root / target).is_file(), f"candidate skill mirror missing on disk: {target}"
if (cursor_root / skill).exists(): assert not (cursor_root / skill).exists(), (
expected = PurePosixPath("gitnexus-cursor-integration/skills", skill, "SKILL.md") f"candidate skill {skill} ships to Cursor, but MIRROR_SKILL_ROOTS does not cover "
assert expected in mirror_targets(PurePosixPath(".claude", "skills", skill, "SKILL.md")) "gitnexus-cursor-integration/skills — promotion would sync it asymmetrically"
)
def test_committed_destination_bases_reject_overlay_adding_uncommitted_target(tmp_path): def test_committed_destination_bases_reject_overlay_adding_uncommitted_target(tmp_path):

View file

@ -9,130 +9,37 @@ import stat
import subprocess import subprocess
import sys import sys
import threading import threading
from dataclasses import replace
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
import pytest import pytest
from workflow_bench import runner, runner_artifacts from workflow_bench import runner
from workflow_bench import proposer_sandbox
from workflow_bench.process_control import ManagedProcessResult, run_managed from workflow_bench.process_control import ManagedProcessResult, run_managed
from workflow_bench.proposer_sandbox import ( from workflow_bench.proposer_sandbox import (
MAX_BUNDLE_BYTES, MAX_BUNDLE_BYTES,
MAX_EVIDENCE_FILE_BYTES, MAX_EVIDENCE_FILE_BYTES,
SANDBOX_CLAUDE,
SANDBOX_NODE, SANDBOX_NODE,
SANDBOX_NODE_PREFIX, SANDBOX_NODE_PREFIX,
SANDBOX_EVIDENCE,
SANDBOX_GITNEXUS_CLI,
SANDBOX_GIT_EXCLUDES,
VITE_TEMP_DIR, VITE_TEMP_DIR,
SANDBOX_PATH, SANDBOX_PATH,
SANDBOX_REVIEW_OUTPUT,
SANDBOX_PYTHON3, SANDBOX_PYTHON3,
SANDBOX_SHELL_PREFIX, SANDBOX_SHELL_PREFIX,
SANDBOX_USER_SKILLS, SANDBOX_USER_SKILLS,
SANDBOX_WORKSPACE,
ReadOnlyMount, ReadOnlyMount,
SandboxError, SandboxError,
_runtime_mount_args, _runtime_mount_args,
build_claude_settings, build_claude_settings,
build_sandbox_environment, build_sandbox_environment,
_force_rmtree,
host_workspace_write_boundary,
prepare_review_workspace,
prepare_sandbox, prepare_sandbox,
preflight_bubblewrap, preflight_bubblewrap,
sandbox_workspace_write_boundary,
stage_evidence_bundle, stage_evidence_bundle,
stage_task_assets, stage_task_assets,
) )
from workflow_bench.review_scoring import REVIEW_OUTPUT, parse_review_output
from workflow_bench.task_assets import TaskAssetCache, stage_task_assets as stage_immutable_task_assets from workflow_bench.task_assets import TaskAssetCache, stage_task_assets as stage_immutable_task_assets
@pytest.mark.parametrize("entry", ["directory", "relative-link", "absolute-link"])
def test_review_preparation_rejects_a_reused_artifact_directory(tmp_path, entry):
clone = tmp_path / "clone"
clone.mkdir()
sentinel = tmp_path / "sentinel"
sentinel.write_text("must survive")
with prepare_sandbox(clone=clone, claude_bin=sys.executable, backend="host-unsafe") as sandbox:
stale = proposer_sandbox.review_output_path(sandbox, "review-output.json").parent
if entry == "directory":
stale.mkdir()
(stale / "review-output.json").write_text("a previous cell's verdict")
else:
# relpath, not a hand-written "../sentinel": stale is
# <private_root>/review-output, which is nowhere near tmp_path, so the
# literal produced a dangling link and the assertion below proved nothing.
stale.symlink_to(
sentinel if entry == "absolute-link" else Path(os.path.relpath(sentinel, stale.parent))
)
with pytest.raises(SandboxError, match="already exists"):
proposer_sandbox.prepare_review_workspace(sandbox, "review-output.json")
assert sentinel.read_text() == "must survive"
def test_review_preparation_leaves_a_clone_entry_of_the_same_name_alone(tmp_path):
# The artifact no longer lives in the workspace, so a file that happens to
# share its name is just one of the repository's own files.
clone = tmp_path / "clone"
clone.mkdir()
(clone / "review-output.json").write_text("repository content")
with prepare_sandbox(clone=clone, claude_bin=sys.executable, backend="host-unsafe") as sandbox:
output = proposer_sandbox.prepare_review_workspace(sandbox, "review-output.json")
assert (clone / "review-output.json").read_text() == "repository content"
assert clone not in output.parents
def test_review_preparation_creates_a_private_directory_and_not_the_file(tmp_path):
clone = tmp_path / "clone"
clone.mkdir()
with prepare_sandbox(clone=clone, claude_bin=sys.executable, backend="host-unsafe") as sandbox:
output = proposer_sandbox.prepare_review_workspace(sandbox, "review-output.json")
assert output == proposer_sandbox.review_output_path(sandbox, "review-output.json")
# The DIRECTORY is what has to exist and be writable: the agent writes
# a temp file beside the target and renames it.
assert output.parent.is_dir()
assert stat.S_IMODE(output.parent.stat().st_mode) == 0o700
# The file is deliberately absent — absence is how "never written" is
# told apart from "written badly".
assert not output.exists()
def test_review_preparation_preserves_existing_runtime_files_and_tracks_only_created_paths(tmp_path):
clone = tmp_path / "clone"
clone.mkdir()
(clone / "bunfig.toml").write_text("existing configuration\n")
with prepare_sandbox(clone=clone, claude_bin=sys.executable, backend="host-unsafe") as sandbox:
proposer_sandbox.prepare_review_workspace(replace(sandbox, backend="bwrap"), "review-output.json")
created = json.loads((sandbox.private_root / "review-created-paths.json").read_text())
assert "bunfig.toml" not in created
assert ".npmrc" in created
assert ".mcp.json" in created
assert json.loads((clone / ".mcp.json").read_text()) == {}
assert (clone / "bunfig.toml").read_text() == "existing configuration\n"
assert (clone / ".git/commondir").read_text() == ".\n"
def test_review_preparation_rejects_a_runtime_symlink_parent(tmp_path):
clone = tmp_path / "clone"
clone.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(clone / ".claude").symlink_to(outside, target_is_directory=True)
with prepare_sandbox(clone=clone, claude_bin=sys.executable, backend="host-unsafe") as sandbox:
with pytest.raises(SandboxError):
proposer_sandbox.prepare_review_workspace(replace(sandbox, backend="bwrap"), "review-output.json")
assert list(outside.iterdir()) == []
def test_environment_is_allowlisted_and_shell_children_are_credential_free(monkeypatch) -> None: def test_environment_is_allowlisted_and_shell_children_are_credential_free(monkeypatch) -> None:
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "cloud-secret") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "cloud-secret")
monkeypatch.setenv("GITHUB_TOKEN", "github-secret") monkeypatch.setenv("GITHUB_TOKEN", "github-secret")
@ -158,11 +65,6 @@ def test_environment_is_allowlisted_and_shell_children_are_credential_free(monke
assert settings["sandbox"]["failIfUnavailable"] is True assert settings["sandbox"]["failIfUnavailable"] is True
assert settings["sandbox"]["allowUnsandboxedCommands"] is False assert settings["sandbox"]["allowUnsandboxedCommands"] is False
assert settings["sandbox"]["network"]["deniedDomains"] == ["*"] assert settings["sandbox"]["network"]["deniedDomains"] == ["*"]
assert SANDBOX_EVIDENCE in settings["sandbox"]["filesystem"]["allowRead"]
# Headless `claude -p` (2.1.247) never dispatches PreToolUse from any
# settings source, so a hook here would be confinement theater: it would
# read as a control in review while enforcing nothing at runtime.
assert "hooks" not in settings
# ENV_SCRUB forces "default" mode; the proposer's tools (Bash writes the # ENV_SCRUB forces "default" mode; the proposer's tools (Bash writes the
# overlay) run headless only because they are explicitly pre-approved. # overlay) run headless only because they are explicitly pre-approved.
# Requesting a non-default defaultMode would merely warn, so it must be gone. # Requesting a non-default defaultMode would merely warn, so it must be gone.
@ -170,169 +72,6 @@ def test_environment_is_allowlisted_and_shell_children_are_credential_free(monke
assert "defaultMode" not in settings["permissions"] assert "defaultMode" not in settings["permissions"]
def test_unsafe_host_session_translates_virtual_paths_and_disables_containment(tmp_path) -> None:
clone = tmp_path / "clone"
evidence = tmp_path / "evidence"
for directory in (clone, evidence):
directory.mkdir()
with prepare_sandbox(
clone=clone,
backend="host-unsafe",
claude_bin=sys.executable,
read_only_mounts=(ReadOnlyMount(evidence, "/evidence"),),
) as sandbox:
assert sandbox.command_prefix == []
assert sandbox.require_pid_namespace is False
assert sandbox.host_path("/workspace/review-output.json") == str(clone / "review-output.json")
assert sandbox.host_path("/evidence/selected-rows.json") == str(evidence / "selected-rows.json")
# The review artifact left the workspace, so the host-unsafe backend has
# to translate its new home too. Untranslated, the review prompt names a
# path that exists on neither backend and the cell writes nothing.
assert sandbox.host_path(
f"{proposer_sandbox.SANDBOX_REVIEW_OUTPUT}/review-output.json"
) == str(proposer_sandbox.review_output_path(sandbox, "review-output.json"))
assert sandbox.host_text(
f"write {proposer_sandbox.SANDBOX_REVIEW_OUTPUT}/review-output.json"
) == f"write {proposer_sandbox.review_output_path(sandbox, 'review-output.json')}"
assert sandbox.host_text("read /evidence and write /workspace/out") == (
f"read {evidence} and write {clone}/out"
)
# Sessions spawn the binary directly, so it must be the host executable
# rather than the sandbox-only mount target.
assert sandbox.claude_bin != SANDBOX_CLAUDE
assert Path(sandbox.claude_bin).exists()
assert sandbox.environment()["HOME"] == str(sandbox.home)
assert "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB" not in sandbox.environment()
assert all(
Path(entry).is_dir() for entry in sandbox.environment()["PATH"].split(":")
)
unsafe_settings = json.loads(sandbox.settings_json)
assert unsafe_settings["sandbox"]["enabled"] is False
assert unsafe_settings["sandbox"]["failIfUnavailable"] is False
assert "disableBypassPermissionsMode" not in unsafe_settings["permissions"]
def test_host_workspace_write_boundary_keeps_only_the_review_artifact_writable(tmp_path) -> None:
clone = tmp_path / "clone"
nested = clone / "src"
nested.mkdir(parents=True)
source = nested / "source.ts"
source.write_text("trusted\n")
output = clone / "review-output.json"
output.write_text("")
original_source_mode = stat.S_IMODE(source.stat().st_mode)
original_output_mode = stat.S_IMODE(output.stat().st_mode)
with host_workspace_write_boundary(clone, writable=(output,)):
with pytest.raises(OSError):
source.write_text("tampered\n")
with pytest.raises(OSError):
(clone / "extra.py").write_text("nope\n")
output.write_text('{"schema_version":1}\n')
assert source.read_text() == "trusted\n"
assert output.read_text() == '{"schema_version":1}\n'
assert not (clone / "extra.py").exists()
assert stat.S_IMODE(source.stat().st_mode) == original_source_mode
assert stat.S_IMODE(output.stat().st_mode) == original_output_mode
def test_host_workspace_write_boundary_keeps_files_under_an_allowed_directory(tmp_path) -> None:
clone = tmp_path / "clone"
artifacts = clone / "artifacts"
artifacts.mkdir(parents=True)
existing = artifacts / "review-output.json"
existing.write_text("{}\n")
(clone / "src").mkdir()
locked = clone / "src" / "source.ts"
locked.write_text("trusted\n")
with host_workspace_write_boundary(clone, writable=(artifacts,)):
existing.write_text('{"schema_version":1}\n')
with pytest.raises(OSError):
locked.write_text("tampered\n")
assert existing.read_text() == '{"schema_version":1}\n'
assert locked.read_text() == "trusted\n"
def test_host_workspace_write_boundary_rejects_a_symlinked_writable_artifact(tmp_path) -> None:
clone = tmp_path / "clone"
clone.mkdir()
target = tmp_path / "outside.json"
target.write_text("{}\n")
output = clone / "review-output.json"
output.symlink_to(target)
with pytest.raises(SandboxError, match="non-symlink"):
with host_workspace_write_boundary(clone, writable=(output,)):
pass
def test_sandbox_workspace_write_boundary_is_noop_unless_host_unsafe(tmp_path) -> None:
clone = tmp_path / "clone"
clone.mkdir()
source = clone / "source.ts"
source.write_text("trusted\n")
bwrap_sandbox = SimpleNamespace(backend="bwrap", clone=clone)
with sandbox_workspace_write_boundary(
bwrap_sandbox,
read_only_workspace=True,
writable=(),
):
source.write_text("still writable under bwrap no-op\n")
assert source.read_text() == "still writable under bwrap no-op\n"
output = clone / "review-output.json"
output.write_text("")
source.write_text("trusted\n")
unsafe = SimpleNamespace(backend="host-unsafe", clone=clone)
with sandbox_workspace_write_boundary(
unsafe,
read_only_workspace=True,
writable=(output,),
):
with pytest.raises(OSError):
source.write_text("tampered\n")
output.write_text("ok\n")
assert source.read_text() == "trusted\n"
assert output.read_text() == "ok\n"
def test_force_rmtree_deletes_nonempty_directories_copied_from_a_locked_workspace(tmp_path) -> None:
locked = tmp_path / "locked"
nested = locked / "gitnexus-shared" / "src"
nested.mkdir(parents=True)
(nested / "index.ts").write_text("export {}\n")
os.chmod(nested, 0o500)
os.chmod(locked / "gitnexus-shared", 0o500)
os.chmod(locked, 0o500)
copied = tmp_path / "sandbox-tmp" / "tmp.XXXX" / "gitnexus-shared"
copied.parent.mkdir(parents=True)
shutil.copytree(locked / "gitnexus-shared", copied)
assert stat.S_IMODE(copied.stat().st_mode) & 0o222 == 0
_force_rmtree(copied.parent)
assert not copied.parent.exists()
def test_host_unsafe_sandbox_cleanup_survives_readonly_tmpdir_copies(tmp_path) -> None:
clone = tmp_path / "clone"
clone.mkdir()
leftover = None
with prepare_sandbox(clone=clone, backend="host-unsafe", claude_bin=sys.executable) as sandbox:
leftover = sandbox.private_root
copied = sandbox.temp / "tmp.XXXX" / "gitnexus-shared" / "src"
copied.mkdir(parents=True)
(copied / "index.ts").write_text("export {}\n")
os.chmod(copied, 0o500)
os.chmod(copied.parent, 0o500)
os.chmod(copied.parent.parent, 0o500)
assert leftover is not None
assert not leftover.exists()
@pytest.mark.parametrize( @pytest.mark.parametrize(
"bad_url", "bad_url",
["https://user:secret@example.test", "https://example.test/path?token=x", "file:///tmp/model"], ["https://user:secret@example.test", "https://example.test/path?token=x", "file:///tmp/model"],
@ -431,16 +170,6 @@ def test_sandbox_command_has_minimal_mounts_and_no_host_root_bind(tmp_path: Path
assert probe.returncode == 0, probe.stderr assert probe.returncode == 0, probe.stderr
assert probe.stdout == f"/home/agent|{SANDBOX_PATH}" assert probe.stdout == f"/home/agent|{SANDBOX_PATH}"
gitnexus_index = argv.index(SANDBOX_GITNEXUS_CLI)
gitnexus_wrapper = Path(argv[gitnexus_index - 1])
assert stat.S_IMODE(gitnexus_wrapper.stat().st_mode) == 0o500
assert "/opt/gitnexus/dist/cli/index.js" in gitnexus_wrapper.read_text()
excludes_index = argv.index(SANDBOX_GIT_EXCLUDES)
excludes = Path(argv[excludes_index - 1])
assert stat.S_IMODE(excludes.stat().st_mode) == 0o400
assert "/.bash_profile" in excludes.read_text().splitlines()
# The evidence-provenance.mjs plan-writer's PATH-scan trusts a Python 3 # The evidence-provenance.mjs plan-writer's PATH-scan trusts a Python 3
# candidate only if it (and its directory) is owned by root or by the # candidate only if it (and its directory) is owned by root or by the
# current process — real /usr/bin/python3 is root-owned on the host, # current process — real /usr/bin/python3 is root-owned on the host,
@ -888,63 +617,6 @@ finally:
assert not (clone / "oracle-leak.txt").exists() assert not (clone / "oracle-leak.txt").exists()
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
)
def test_read_only_review_workspace_exposes_only_one_writable_artifact(tmp_path: Path) -> None:
clone = tmp_path / "clone"
clone.mkdir()
source = clone / "source.ts"
source.write_text("trusted\n")
script = """
import os
from pathlib import Path
try:
Path('/workspace/source.ts').write_text('tampered')
except OSError:
pass
else:
raise SystemExit('review source remained writable')
# Write the way the agent's Write tool does: a temp file beside the target,
# then rename. Writing in place would pass against the mount shape that
# shipped every artifact empty, which is the regression this canary exists for.
target = Path('/review-output/review-output.json')
staging = target.with_name(target.name + '.tmp.1.abc')
staging.write_text('{"schema_version":1}')
os.replace(staging, target)
"""
with prepare_sandbox(clone=clone, claude_bin=Path(sys.executable), preflight=True) as sandbox:
output = proposer_sandbox.prepare_review_workspace(sandbox, "review-output.json")
result = run_managed(
[
*sandbox.command_prefix_for(
read_only_workspace=True,
extra_writable_mounts=(
ReadOnlyMount(
source=output.parent, target=proposer_sandbox.SANDBOX_REVIEW_OUTPUT
),
),
),
"/usr/bin/python3",
"-c",
script,
],
timeout=10,
env=sandbox.environment(),
require_pid_namespace=True,
)
# Inside the sandbox scope: the artifact now lives under the session's
# private root, which prepare_sandbox removes on exit. run_arm reads it
# here too, while the session is still alive.
assert result.ok, result.stderr_tail
assert source.read_text() == "trusted\n"
assert output.read_text() == '{"schema_version":1}'
# The staging file is gone: the rename landed rather than a copy.
assert list(output.parent.iterdir()) == [output]
@pytest.mark.skipif(os.name == "nt", reason="symlink creation may require elevated Windows privileges") @pytest.mark.skipif(os.name == "nt", reason="symlink creation may require elevated Windows privileges")
@pytest.mark.parametrize("operation", ["stage", "sandbox"]) @pytest.mark.parametrize("operation", ["stage", "sandbox"])
def test_clone_root_symlink_is_rejected_before_host_access(tmp_path: Path, operation: str) -> None: def test_clone_root_symlink_is_rejected_before_host_access(tmp_path: Path, operation: str) -> None:
@ -1167,15 +839,13 @@ def test_clone_controlled_mcp_replacement_is_never_executed_or_credentialed(tmp_
os.environ.get("GITNEXUS_REQUIRE_CLAUDE_CANARY") != "1", os.environ.get("GITNEXUS_REQUIRE_CLAUDE_CANARY") != "1",
reason="real Claude/Bash/MCP canary is mandatory in the named Ubuntu CI job", reason="real Claude/Bash/MCP canary is mandatory in the named Ubuntu CI job",
) )
@pytest.mark.parametrize("review_layout", [False, True]) def test_real_claude_bare_auth_inner_sandbox_and_mcp_permissions(tmp_path: Path) -> None:
def test_real_claude_auth_inner_sandbox_and_mcp_permissions(tmp_path: Path, review_layout: bool) -> None:
"""Exercise the exact CLI boundary without contacting a paid model.""" """Exercise the exact CLI boundary without contacting a paid model."""
claude = Path(os.environ["CLAUDE_CANARY_BIN"]).resolve() claude = Path(os.environ["CLAUDE_CANARY_BIN"]).resolve()
assert claude.is_file() assert claude.is_file()
clone = tmp_path / "clone" clone = tmp_path / "clone"
clone.mkdir() clone.mkdir()
(clone / "canary.txt").write_text("hook-readable")
fake_mcp = clone / "fake_mcp.py" fake_mcp = clone / "fake_mcp.py"
fake_mcp.write_text( fake_mcp.write_text(
"""import json """import json
@ -1202,7 +872,7 @@ for line in sys.stdin:
}] }]
} }
elif method == "tools/call": elif method == "tools/call":
Path("/tmp/mcp-called").write_text("ok") Path("/workspace/mcp-called").write_text("ok")
result = {"content": [{"type": "text", "text": "repository list ready"}]} result = {"content": [{"type": "text", "text": "repository list ready"}]}
else: else:
result = {} result = {}
@ -1211,35 +881,6 @@ for line in sys.stdin:
) )
fake_mcp.chmod(0o500) fake_mcp.chmod(0o500)
review_command = """test -z "${ANTHROPIC_API_KEY:-}" && python3 - <<'PY'
import json
import os
import subprocess
from pathlib import Path
source = Path('/workspace/canary.txt')
assert 'hook-readable' in source.read_text()
assert 'changed for review' in subprocess.check_output(['git', 'diff', '--', 'canary.txt'], text=True)
for operation in (lambda: source.write_text('forbidden'), lambda: source.rename(source.with_name('renamed')), source.unlink):
try:
operation()
except OSError:
pass
else:
raise AssertionError('source mutation was allowed')
target = Path('/review-output/review-output.json')
staging = target.with_name(target.name + '.tmp.1.abc')
staging.write_text(json.dumps({'schema_version': 1, 'verdict': 'approve', 'findings': []}))
os.replace(staging, target)
PY"""
if review_layout:
for command in (
["git", "init", "-q"],
["git", "add", "canary.txt", "fake_mcp.py"],
["git", "-c", "user.name=Canary", "-c", "user.email=canary@example.test", "commit", "-qm", "fixture"],
):
subprocess.run(command, cwd=clone, check=True, capture_output=True)
(clone / "canary.txt").write_text("hook-readable\nchanged for review\n")
observed_tool_results: dict[str, dict] = {} observed_tool_results: dict[str, dict] = {}
class ModelHandler(BaseHTTPRequestHandler): class ModelHandler(BaseHTTPRequestHandler):
@ -1269,19 +910,7 @@ PY"""
and isinstance(block.get("tool_use_id"), str) and isinstance(block.get("tool_use_id"), str)
} }
) )
if "toolu_read_canary" not in tool_result_ids: if "toolu_mcp_canary" not in tool_result_ids:
blocks = [
{
"type": "tool_use",
"id": "toolu_read_canary",
"name": "Read",
"input": {
"file_path": "/workspace/canary.txt",
},
}
]
stop_reason = "tool_use"
elif "toolu_mcp_canary" not in tool_result_ids:
blocks = [ blocks = [
{ {
"type": "tool_use", "type": "tool_use",
@ -1298,9 +927,7 @@ PY"""
"id": "toolu_bash_canary", "id": "toolu_bash_canary",
"name": "Bash", "name": "Bash",
"input": { "input": {
"command": review_command "command": ('test -z "${ANTHROPIC_API_KEY:-}" && printf canary > /workspace/bash-called')
if review_layout
else ('test -z "${ANTHROPIC_API_KEY:-}" && printf canary > /workspace/bash-called')
}, },
} }
] ]
@ -1397,22 +1024,6 @@ PY"""
} }
) )
with prepare_sandbox(clone=clone, claude_bin=claude, preflight=True) as sandbox: with prepare_sandbox(clone=clone, claude_bin=claude, preflight=True) as sandbox:
output = None
before = {}
if review_layout:
output = proposer_sandbox.prepare_review_workspace(sandbox, "review-output.json")
before = runner_artifacts.workspace_snapshot(clone)
sandbox = replace(
sandbox,
command_prefix=sandbox.command_prefix_for(
read_only_workspace=True,
extra_writable_mounts=(
ReadOnlyMount(
output.parent, proposer_sandbox.SANDBOX_REVIEW_OUTPUT
),
),
),
)
result = sandbox.run( result = sandbox.run(
[ [
sandbox.claude_bin, sandbox.claude_bin,
@ -1421,6 +1032,7 @@ PY"""
"text", "text",
"--output-format", "--output-format",
"json", "json",
"--bare",
"--settings", "--settings",
sandbox.settings_json, sandbox.settings_json,
"--strict-mcp-config", "--strict-mcp-config",
@ -1432,12 +1044,7 @@ PY"""
# authoritative empirical gate for that behavior. # authoritative empirical gate for that behavior.
"--model", "--model",
"claude-canary-20260718", "claude-canary-20260718",
"--tools",
"Read",
"Bash",
"mcp__gitnexus__list_repos",
"--allowedTools", "--allowedTools",
"Read",
"Bash", "Bash",
"mcp__gitnexus__list_repos", "mcp__gitnexus__list_repos",
], ],
@ -1446,121 +1053,17 @@ PY"""
auth_token="offline-canary-key", auth_token="offline-canary-key",
base_url=f"http://127.0.0.1:{server.server_port}", base_url=f"http://127.0.0.1:{server.server_port}",
), ),
stdin_data=b"Use all three available tools, then finish.", stdin_data=b"Use both available tools, then finish.",
) )
assert result.ok, result.stderr_tail + result.stdout_tail
report = json.loads(result.stdout_tail)
assert report["subtype"] == "success" and report["is_error"] is False, report
read_result = observed_tool_results["toolu_read_canary"]
assert read_result.get("is_error") is not True, read_result
assert "hook-readable" in json.dumps(read_result)
bash_result = observed_tool_results["toolu_bash_canary"]
assert bash_result.get("is_error") is not True, bash_result
assert (sandbox.temp / "mcp-called").read_text() == "ok"
if review_layout:
assert output is not None
runner_artifacts.enforce_phase_workspace(clone, before, allowed_artifact=None)
assert json.loads(output.read_text())["verdict"] == "approve"
assert (clone / "canary.txt").read_text() == "hook-readable\nchanged for review\n"
finally: finally:
server.shutdown() server.shutdown()
server.server_close() server.server_close()
thread.join(timeout=5) thread.join(timeout=5)
if not review_layout: assert result.ok, result.stderr_tail + result.stdout_tail
assert (clone / "bash-called").read_text() == "canary" report = json.loads(result.stdout_tail)
assert report["subtype"] == "success" and report["is_error"] is False, report
bash_result = observed_tool_results["toolu_bash_canary"]
def test_review_artifact_binds_a_writable_directory_outside_the_workspace(tmp_path): assert bash_result.get("is_error") is not True, bash_result
"""The bwrap argv, since the mount shape is the whole bug. assert (clone / "bash-called").read_text() == "canary"
assert (clone / "mcp-called").read_text() == "ok"
bwrap cannot create a mount point inside an already-read-only bind, so a
writable path has to live outside /workspace — and it has to be the
directory, or the agent has nowhere to put the temp file it renames into
place.
"""
clone = tmp_path / "clone"
clone.mkdir()
with prepare_sandbox(clone=clone, claude_bin=sys.executable, backend="host-unsafe") as session:
sandbox = replace(session, backend="bwrap")
output = proposer_sandbox.review_output_path(sandbox, "review-output.json")
output.parent.mkdir(mode=0o700)
argv = sandbox.command_prefix_for(
read_only_workspace=True,
extra_writable_mounts=(
proposer_sandbox.ReadOnlyMount(
source=output.parent,
target=proposer_sandbox.SANDBOX_REVIEW_OUTPUT,
),
),
)
target = proposer_sandbox.SANDBOX_REVIEW_OUTPUT
assert not target.startswith(proposer_sandbox.SANDBOX_WORKSPACE + "/")
# The workspace itself is bound read-only...
workspace_at = argv.index(proposer_sandbox.SANDBOX_WORKSPACE)
assert argv[workspace_at - 2] == "--ro-bind"
# ...and the artifact directory is bound writable, as a directory.
artifact_at = argv.index(target)
assert argv[artifact_at - 2] == "--bind"
assert Path(argv[artifact_at - 1]) == output.parent
assert Path(argv[artifact_at - 1]).is_dir()
assert f"{proposer_sandbox.SANDBOX_WORKSPACE}/review-output.json" not in argv
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
)
def test_real_bubblewrap_lets_a_review_artifact_be_written_atomically(tmp_path: Path) -> None:
"""The filesystem contract the EROFS defect broke, under a real sandbox.
Argv assertions cannot establish this. The artifact came back empty because
an atomic write - temp file beside the target, then rename - needs a
WRITABLE PARENT DIRECTORY, and only a real bwrap invocation shows whether
the mount grants one. A deterministic writer stands in for the agent: no
model session, no credentials.
Scope: this proves the filesystem and process contract of the production
mount configuration. It does not establish that a particular agent CLI's
own file-access policy permits the same operation - that is a second,
independent gate.
"""
clone = tmp_path / "clone"
clone.mkdir()
(clone / "tracked.txt").write_text("original\n")
with prepare_sandbox(clone=clone, claude_bin=Path(sys.executable), preflight=True) as sandbox:
review_output = prepare_review_workspace(sandbox, REVIEW_OUTPUT)
# The production configuration, not a hand-built mount tuple: the same
# command_prefix_for call run_arm makes for a review cell.
prefix = sandbox.command_prefix_for(
read_only_workspace=True,
extra_writable_mounts=(
ReadOnlyMount(source=review_output.parent, target=SANDBOX_REVIEW_OUTPUT),
),
)
target = f"{SANDBOX_REVIEW_OUTPUT}/{REVIEW_OUTPUT}"
script = (
# 1. temp file beside the destination, then atomic rename over it.
f'printf %s \'{{"schema_version": 1, "verdict": "approve", "findings": []}}\' > {target}.tmp && '
f"mv {target}.tmp {target} && "
# 2. the workspace must refuse the write that the mount forbids.
f"(printf x >> {SANDBOX_WORKSPACE}/tracked.txt 2>/dev/null && echo WORKSPACE-WRITABLE || echo workspace-readonly)"
)
result = subprocess.run(
[*prefix, "/bin/sh", "-c", script],
capture_output=True, text=True, timeout=60, check=False,
)
assert result.returncode == 0, f"atomic write failed inside the sandbox: {result.stderr[-400:]}"
assert "workspace-readonly" in result.stdout, "the workspace must stay read-only"
assert (clone / "tracked.txt").read_text() == "original\n", "the clone was modified"
# Read while the session is alive: the artifact lives under the private
# root that prepare_sandbox removes on exit, which is also why run_arm
# consumes it before leaving the scope.
_verdict, findings = parse_review_output(review_output)
assert findings == ()

View file

@ -1,132 +0,0 @@
"""The two providers' accounting equations, encoded literally.
Adding OpenAI's cache fields to its input_tokens double-counts, because they are
subsets of it. Subtracting Anthropic's under-counts, because they are additional
categories. A single generic struct cannot be right for both, so these tests
pin each equation rather than the field names.
"""
from __future__ import annotations
import pytest
from workflow_bench.provider_usage import (
ANTHROPIC,
OPENAI_RESPONSES,
UsageSemanticsError,
normalize_usage,
)
def _openai(input_tokens: int, cached: int | None = None, cache_write: int | None = None) -> dict:
details: dict[str, int] = {}
if cached is not None:
details["cached_tokens"] = cached
if cache_write is not None:
details["cache_write_tokens"] = cache_write
return {
"input_tokens": input_tokens,
"input_tokens_details": details,
"output_tokens": 300,
"output_tokens_details": {"reasoning_tokens": 250},
}
def test_openai_uncached_request_is_all_ordinary_input() -> None:
usage = normalize_usage(OPENAI_RESPONSES, _openai(1000, cached=0, cache_write=0))
assert usage.ordinary_input_tokens == 1000
assert usage.total_input_tokens == 1000
assert (usage.cache_read_input_tokens, usage.cache_write_input_tokens) == (0, 0)
def test_openai_cache_creation_keeps_the_parts_summing_to_input_tokens() -> None:
"""The subsets must reconstruct the whole, never exceed it."""
usage = normalize_usage(OPENAI_RESPONSES, _openai(1000, cached=0, cache_write=400))
assert usage.ordinary_input_tokens == 600
assert (
usage.ordinary_input_tokens
+ usage.cache_read_input_tokens
+ usage.cache_write_input_tokens
== usage.total_input_tokens
)
def test_openai_cache_hit_plus_new_write_uses_the_documented_subtraction() -> None:
usage = normalize_usage(OPENAI_RESPONSES, _openai(10_000, cached=7_000, cache_write=1_000))
assert usage.ordinary_input_tokens == 2_000
assert usage.total_input_tokens == 10_000, "input_tokens is the whole, not a component"
def test_openai_reasoning_tokens_decompose_output_rather_than_adding_to_it() -> None:
usage = normalize_usage(OPENAI_RESPONSES, _openai(100, cached=0, cache_write=0))
assert usage.output_tokens == 300
assert usage.reasoning_output_tokens == 250
assert usage.reasoning_output_tokens <= usage.output_tokens
def test_anthropic_uncached_total_is_just_input_tokens() -> None:
usage = normalize_usage(
ANTHROPIC,
{"input_tokens": 1000, "cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0, "output_tokens": 200},
)
assert usage.total_input_tokens == 1000
assert usage.ordinary_input_tokens == 1000
def test_anthropic_cached_total_adds_the_cache_categories() -> None:
"""The opposite equation to OpenAI's, on deliberately identical numbers."""
usage = normalize_usage(
ANTHROPIC,
{"input_tokens": 2_000, "cache_creation_input_tokens": 1_000,
"cache_read_input_tokens": 7_000, "output_tokens": 200},
)
assert usage.total_input_tokens == 10_000
assert usage.ordinary_input_tokens == 2_000
def test_the_same_numbers_mean_different_totals_on_the_two_providers() -> None:
"""The whole reason a shared struct is unsafe, in one assertion."""
openai = normalize_usage(OPENAI_RESPONSES, _openai(10_000, cached=7_000, cache_write=1_000))
anthropic = normalize_usage(
ANTHROPIC,
{"input_tokens": 10_000, "cache_creation_input_tokens": 1_000,
"cache_read_input_tokens": 7_000, "output_tokens": 300},
)
assert openai.total_input_tokens == 10_000
assert anthropic.total_input_tokens == 18_000
assert openai.ordinary_input_tokens == 2_000
assert anthropic.ordinary_input_tokens == 10_000
def test_missing_native_cache_fields_are_unknown_and_never_zero() -> None:
"""A zero we invented is indistinguishable from a zero the provider reported."""
usage = normalize_usage(OPENAI_RESPONSES, {"input_tokens": 1000, "output_tokens": 10})
assert usage.cache_read_input_tokens is None
assert usage.cache_write_input_tokens is None
assert usage.ordinary_input_tokens is None, "cannot subtract what was never reported"
assert usage.total_input_tokens == 1000
assert not usage.complete
assert "cache_read_input_tokens" in usage.unknown_fields
def test_an_absent_usage_object_is_entirely_unknown() -> None:
usage = normalize_usage(ANTHROPIC, None)
assert not usage.complete
assert usage.total_input_tokens is None
def test_an_unknown_provider_is_refused_rather_than_guessed() -> None:
with pytest.raises(UsageSemanticsError, match="refusing to guess"):
normalize_usage("some-new-provider", {"input_tokens": 1})
def test_cache_subsets_larger_than_the_whole_are_rejected() -> None:
"""Nonsense arithmetic must surface, not silently produce a negative."""
with pytest.raises(UsageSemanticsError, match="exceed input_tokens"):
normalize_usage(OPENAI_RESPONSES, _openai(100, cached=90, cache_write=50))

View file

@ -1,327 +0,0 @@
"""What the proxy writes must outlive the translation that follows it.
Claude Code receives an Anthropic-shaped response, which has nowhere to put
OpenAI's cached_tokens, cache_write_tokens or reasoning_tokens. If those are not
captured before the translation, the only remaining record of them is a bill.
"""
from __future__ import annotations
import contextlib
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from workflow_bench import litellm_usage_callback, provider_usage
from workflow_bench.litellm_usage_callback import USAGE_LOG_ENV_VAR, ProviderUsageLogger
from workflow_bench.model_gateway import (
OpenAIGateway,
USAGE_CALLBACK_MODULE,
openai_litellm_config,
write_openai_litellm_config,
)
from workflow_bench.provider_usage import (
ANTHROPIC,
LITELLM_NORMALIZED,
USAGE_ENV_VARS,
normalize_usage,
)
class _Usage:
"""Stands in for the provider usage model LiteLLM hands the callback."""
def __init__(self, payload: dict) -> None:
self._payload = payload
def model_dump(self) -> dict:
return self._payload
def _openai_response(usage: dict) -> SimpleNamespace:
return SimpleNamespace(
id="resp_68f2c1",
# The model that actually answered, which is not the role the caller asked for.
model="gpt-5.6-sol-2026-08-01",
usage=_Usage(usage),
)
# The shape a callback actually receives: LiteLLM normalises usage into its own
# Chat-Completions-style object before any logger sees it, so an OpenAI reply
# arrives as prompt_tokens / prompt_tokens_details. Confirmed against a real
# proxy in tests/test_mock_provider.py; a fixture in the wire shape would test
# an object this code path never gets.
NATIVE = {
"prompt_tokens": 48_000,
"prompt_tokens_details": {"cached_tokens": 44_000, "cache_write_tokens": 1_000},
"completion_tokens": 900,
"completion_tokens_details": {"reasoning_tokens": 640},
}
@pytest.fixture
def logged(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
log = tmp_path / "provider_usage.jsonl"
monkeypatch.setenv(USAGE_LOG_ENV_VAR, str(log))
def emit(usage: dict) -> dict:
ProviderUsageLogger()._append(
"success",
{"model": "claude-sonnet-4-5", "custom_llm_provider": "openai", "call_type": "responses"},
_openai_response(usage),
0.0,
1.0,
)
return json.loads(log.read_text().splitlines()[-1])
return emit
def test_native_openai_usage_survives_the_anthropic_translation(logged) -> None:
event = logged(NATIVE)
native = event["native_usage"]
# Verbatim: the fields an Anthropic-shaped response cannot carry.
assert native["prompt_tokens_details"]["cached_tokens"] == 44_000
assert native["prompt_tokens_details"]["cache_write_tokens"] == 1_000
assert native["completion_tokens_details"]["reasoning_tokens"] == 640
assert event["response_id"] == "resp_68f2c1"
def test_the_actual_model_is_recorded_separately_from_the_requested_role(logged) -> None:
"""Pricing must follow what answered, not what the caller named."""
event = logged(NATIVE)
assert event["requested_model"] == "claude-sonnet-4-5"
assert event["actual_model"] == "gpt-5.6-sol-2026-08-01"
assert "cell_id" not in event, "a proxy-wide variable cannot identify a cell"
def test_the_captured_event_normalizes_with_openai_arithmetic(logged) -> None:
"""Capture and normalization must agree end to end, not just in isolation."""
event = logged(NATIVE)
# The provider the LOG recorded, not one the test supplies - passing
# OPENAI_RESPONSES by hand here is what hid the adapter-key mismatch.
# LITELLM_NORMALIZED, not OPENAI_RESPONSES: a proxy callback never sees the
# upstream body. Measured against a real gateway - the Responses adapter
# found none of its keys there and reported every field unknown.
assert event["provider"] == LITELLM_NORMALIZED
assert event["provider_label"] == "openai"
usage = normalize_usage(event["provider"], event["native_usage"])
assert usage.total_input_tokens == 48_000
assert usage.ordinary_input_tokens == 3_000
assert usage.cache_read_input_tokens == 44_000
assert usage.complete
def test_usage_without_details_normalizes_to_unknown_rather_than_zero(logged) -> None:
"""The mutation the accounting must not survive: dropped details, silent zeros."""
stripped = {k: v for k, v in NATIVE.items() if k != "prompt_tokens_details"}
event = logged(stripped)
usage = normalize_usage(event["provider"], event["native_usage"])
assert usage.cache_read_input_tokens is None
assert usage.ordinary_input_tokens is None
assert not usage.complete
def test_a_failed_request_is_still_accounted_for(logged, tmp_path: Path) -> None:
"""The money was spent whether or not the cell produced an artifact."""
import asyncio
logger = ProviderUsageLogger()
args = ({"model": "claude-sonnet-4-5"}, _openai_response(NATIVE), 0.0, 1.0)
# Every hook LiteLLM can call, not the private helper underneath them: the
# sync failure hook was missing entirely and _append could never show that.
logger.log_failure_event(*args)
asyncio.run(logger.async_log_failure_event(*args))
events = [json.loads(line) for line in (tmp_path / "provider_usage.jsonl").read_text().splitlines()]
assert len(events) == 2, "both failure hooks must record"
assert all(e["status"] == "failure" for e in events)
def test_every_public_outcome_hook_records(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Overriding a subset silently drops whichever path LiteLLM actually uses."""
import asyncio
monkeypatch.setenv(USAGE_LOG_ENV_VAR, str(tmp_path / "usage.jsonl"))
logger = ProviderUsageLogger()
args = ({"model": "m"}, _openai_response(NATIVE), 0.0, 1.0)
logger.log_success_event(*args)
logger.log_failure_event(*args)
asyncio.run(logger.async_log_success_event(*args))
asyncio.run(logger.async_log_failure_event(*args))
events = [json.loads(line) for line in (tmp_path / "usage.jsonl").read_text().splitlines()]
assert [e["status"] for e in events] == ["success", "failure", "success", "failure"]
def test_the_logger_never_raises_into_the_proxy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Accounting is evidence, not control flow."""
monkeypatch.setenv(USAGE_LOG_ENV_VAR, str(tmp_path / "missing-dir" / "usage.jsonl"))
ProviderUsageLogger()._append("success", {}, object(), 0.0, 1.0)
def test_no_log_is_written_when_the_destination_is_unset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(USAGE_LOG_ENV_VAR, raising=False)
ProviderUsageLogger()._append("success", {}, _openai_response(NATIVE), 0.0, 1.0)
assert not list(tmp_path.iterdir())
def test_the_generated_config_loads_the_callback_from_beside_itself(tmp_path: Path) -> None:
"""LiteLLM resolves the dotted path relative to the config directory."""
config = write_openai_litellm_config(tmp_path / "litellm.yaml", ["gpt-5.6-sol"])
assert openai_litellm_config(["gpt-5.6-sol"])["litellm_settings"]["callbacks"] == [
f"{USAGE_CALLBACK_MODULE}.handler"
]
installed = config.parent / f"{USAGE_CALLBACK_MODULE}.py"
assert installed.is_file(), "the proxy cannot import a callback that was never placed"
# Importing it, not grepping it: a text search passes even when the module
# cannot load, which is exactly how a package-relative import survived
# review here. This is the deployment configuration, so load it the way the
# proxy does - by path, as a top-level module.
import importlib.util
spec = importlib.util.spec_from_file_location(USAGE_CALLBACK_MODULE, installed)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert isinstance(module.handler, module.ProviderUsageLogger)
def test_the_gateway_forwards_the_usage_environment_into_the_proxy(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The proxy is a separate process with a constructed environment.
Popen(env=...) replaces the parent environment rather than extending it, so
a variable the callback reads is simply absent unless the gateway forwards
it by name. Without this the accounting looks configured and silently
records nothing on every request - the in-process tests above cannot see
that, because they never cross the subprocess boundary.
"""
for name in USAGE_ENV_VARS:
monkeypatch.setenv(name, f"value-for-{name}")
captured: dict[str, dict[str, str]] = {}
class _Popen:
def __init__(self, *_a, **kwargs):
captured["env"] = kwargs["env"]
raise RuntimeError("stop before launching a real proxy")
# The console-script resolver runs before Popen and is absent in this
# environment (the same reason two gateway tests fail here); the argv it
# builds is not what this test is about.
monkeypatch.setattr(
"workflow_bench.model_gateway.litellm_proxy_argv",
lambda **_k: ["/bin/true"],
)
monkeypatch.setattr("workflow_bench.model_gateway.subprocess.Popen", _Popen)
gateway = OpenAIGateway(
openai_api_key="sk-test",
model_names=["gpt-5.6-sol"],
work_dir=tmp_path,
)
with contextlib.suppress(Exception):
gateway.__enter__()
env = captured.get("env")
assert env is not None, "the proxy was never constructed"
for name in USAGE_ENV_VARS:
assert env.get(name) == f"value-for-{name}", f"{name} never reached the proxy"
# The credential allowlist is still an allowlist, not the parent environment.
assert "PATH" in env and len(env) < 40
def test_an_unresolvable_provider_is_refused_rather_than_guessed() -> None:
"""LiteLLM says "openai" for Chat Completions too, and it counts differently."""
from workflow_bench.provider_usage import canonical_provider
# Every openai call reaching this callback has already been normalised by
# LiteLLM, whatever endpoint it used - the observed call_type for a Claude
# Code request through the gateway is "anthropic_messages". The adapter has
# to match the object in hand, not the protocol on the wire.
assert canonical_provider("openai", "responses") == LITELLM_NORMALIZED
assert canonical_provider("openai", "anthropic_messages") == LITELLM_NORMALIZED
assert canonical_provider("anthropic", "completion") == ANTHROPIC
# An unrecognised provider is still refused rather than guessed.
assert canonical_provider("some-new-provider", "responses") is None
assert canonical_provider(None, None) is None
def test_request_identity_cannot_come_from_the_proxy_environment() -> None:
"""One proxy serves the whole sweep, so its environment identifies the sweep.
attach_openai_gateway wraps all of _run_sweep, and cells run concurrently
under --workers, interleaving requests through that single process. Any
variable forwarded at launch is therefore constant for every event it ever
records. Pinned so a future change does not reintroduce a per-cell
environment variable that would silently stamp one value on every request.
"""
assert USAGE_ENV_VARS == (
"GITNEXUS_BENCH_PROVIDER_USAGE",
"GITNEXUS_BENCH_SWEEP_ID",
), "a per-cell variable here would be constant across concurrent cells"
def test_a_request_records_its_session_so_attribution_stays_possible(logged) -> None:
"""The per-request half of identity, recorded even when the provider omits it."""
event = logged(NATIVE)
assert "session_id" in event, "absent attribution is still a fact about the run"
def test_the_callback_imports_the_way_litellm_actually_loads_it(tmp_path: Path) -> None:
"""By path, as a top-level module, with no parent package and no sys.path entry.
LiteLLM resolves a dotted callback through spec_from_file_location against
the config directory, so the copied file is not part of workflow_bench when
it runs. A relative or sibling import therefore raises ImportError and the
proxy exits before becoming ready - which the in-package tests cannot see,
because they import it as workflow_bench.litellm_usage_callback.
"""
import importlib.util
import shutil
source = Path(litellm_usage_callback.__file__)
installed = tmp_path / f"{USAGE_CALLBACK_MODULE}.py"
shutil.copy(source, installed)
spec = importlib.util.spec_from_file_location(USAGE_CALLBACK_MODULE, installed)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # ImportError here is the proxy refusing to start
assert hasattr(module, "handler")
def test_the_callbacks_copied_constants_match_the_canonical_ones() -> None:
"""The copies are deliberate; drifting apart silently is not.
The callback cannot import from the package (see the test above), so it
carries its own literals. These assertions are what keep the duplication
honest.
"""
assert litellm_usage_callback.USAGE_LOG_ENV_VAR == provider_usage.USAGE_LOG_ENV_VAR
assert litellm_usage_callback.SWEEP_ID_ENV_VAR == provider_usage.SWEEP_ID_ENV_VAR
for label, call_type in (
("openai", "responses"),
("openai", "completion"),
("openai", None),
("anthropic", "completion"),
("mystery", "responses"),
):
assert litellm_usage_callback.canonical_provider(label, call_type) == provider_usage.canonical_provider(
label, call_type
), f"resolver drifted for {label!r}/{call_type!r}"

View file

@ -1,268 +0,0 @@
"""A row the runner actually emits must satisfy the reuse reader.
Every existing comparator-reuse test builds its rows by hand. That proves the
predicate's logic and nothing about the producer: a fixture can satisfy
eligibility while a real emitted row never does, and the audit that counts key
names cannot tell the difference. These tests carry one record through the
production path instead:
real run_cell -> production JSONL writer -> load_result_rows
-> row_is_reusable_comparator
Only the expensive dependencies are replaced - the model session, sandbox
launch, repository acquisition, graph preparation. The digest fields the reuse
binding compares are assembled by run_cell itself from its TaskCellContext, so
they stay real: they are the subject of the test, not scaffolding around it.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from workflow_bench import runner
from workflow_bench.proposer_sandbox import redact_text
from workflow_bench.model_gateway import credential_secrets
from workflow_bench.runner_sessions import PARENT_EVENT_STREAM_SOURCE
from workflow_bench.comparator_reuse import (
ComparatorReuseExpectation,
TaskReuseBinding,
load_result_rows,
row_is_reusable_comparator,
)
TASK_ID = "review-pr-2718-defect"
SHA = "a" * 40
def _snapshot(prefix: str) -> SimpleNamespace:
return SimpleNamespace(
digest=f"{prefix}-content",
manifest_digest=f"{prefix}-manifest",
dependency_content_digest=f"{prefix}-dep-content",
dependency_manifest_digest=f"{prefix}-dep-manifest",
command_digest=f"{prefix}-command",
materialize=lambda *a, **k: None,
)
def _write_like_the_sweep(tmp_path: Path, row: dict[str, Any]) -> Path:
"""Serialize exactly as ``keep`` does in _run_sweep, redaction included.
json.dumps + write_text would skip the redaction the real writer applies,
so a change there could break reusable rows without failing this test - and
redaction is not cosmetic here, since it rewrites the row's own bytes.
"""
results = tmp_path / "results.jsonl"
secrets = credential_secrets(
SimpleNamespace(auth_token="sk-ant-should-never-appear", base_url=None)
)
with results.open("a") as handle:
handle.write(redact_text(json.dumps(row), secrets) + "\n")
return results
@pytest.fixture
def emitted_row(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
"""One record from the real run_cell, with only expensive work replaced."""
worktree = tmp_path / "clone"
worktree.mkdir()
# The session is what costs money; everything it returns is scripted. The
# record's binding fields are NOT set here - run_cell derives them.
def fake_run_arm(*_a: Any, **_k: Any) -> dict[str, Any]:
return {
"ok": True,
"error_kind": None,
"error_detail": None,
"resolved": True,
"review_evidence_valid": True,
"review_score": {"weighted_f1": 0.5},
"review_weighted_f1": 0.5,
"skill_invoked": True,
"skill_digest": "skill-digest",
"transcript_missing": False,
"transcript_artifacts": [
{
"path": "transcripts/session-1.jsonl",
"sha256": __import__("hashlib").sha256(b'{"type":"ok"}\n').hexdigest(),
"bytes": 14,
"source": PARENT_EVENT_STREAM_SOURCE,
}
],
"session_ids": ["s1"],
"num_turns": 3,
"duration_s": 1.0,
"cost_usd": 0.5,
"input_tokens": 1,
"output_tokens": 1,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
}
for name, value in {
"run_arm": fake_run_arm,
"copy_isolated_tree": lambda *a, **k: worktree,
"make_worktree": lambda *a, **k: worktree,
"sanitize_clone_for_hidden_oracles": lambda *a, **k: SHA,
"stage_task_assets": lambda *a, **k: (),
"isolated_gitnexus_registry_mount": lambda *a, **k: None,
"seed_evaluated_skills": lambda *a, **k: None,
"apply_candidate_overlay": lambda *a, **k: None,
"require_hidden_harness_absent": lambda *a, **k: None,
"require_skill_fingerprint": lambda *a, **k: None,
"enforce_work_evidence": lambda *a, **k: None,
"skill_fingerprint": lambda *a, **k: "skill-digest",
"capture_patch": lambda *a, **k: b"",
"implementation_diff_digest": lambda *a, **k: "",
"diff_churn": lambda *a, **k: {},
"_prepare_untracked_for_diff": lambda *a, **k: None,
"remove_clone": lambda *a, **k: None,
"ce_plugin_dir_for_arm": lambda *a, **k: None,
"ce_plugin_mounts_for_arm": lambda *a, **k: (),
"current_runtime_digest": lambda: "runtime-digest",
"build_sandbox_environment": lambda *a, **k: {},
"credential_secrets": lambda *a, **k: (),
# run_cell requires an immutable base commit before it will record a
# cell; the git plumbing is expensive setup, the SHA it returns is not
# part of the reuse binding under test.
"_sandbox_git": lambda *a, **k: SHA,
# The artifact copy is real; only the read of the agent-written file is
# replaced, since no agent ran to write one.
"_bounded_regular_bytes": lambda *a, **k: b'{"schema_version":1}',
}.items():
monkeypatch.setattr(runner, name, value)
class _Sandbox:
clone = worktree
private_root = tmp_path / "private"
backend = "test-double"
settings_json = "{}"
require_pid_namespace = False
def __enter__(self) -> _Sandbox:
return self
def __exit__(self, *_exc: Any) -> bool:
return False
def command_prefix_for(self, **_k: Any) -> list[str]:
return []
def run(self, *_a: Any, **_k: Any) -> SimpleNamespace:
return SimpleNamespace(ok=True, returncode=0, stdout_tail="", stderr_tail="")
def environment(self, **_k: Any) -> dict[str, str]:
return {}
def host_text(self, value: str) -> str:
return value
monkeypatch.setattr(runner, "prepare_sandbox", lambda **_k: _Sandbox())
ctx = runner.TaskCellContext(
task={"id": TASK_ID, "prompt": "review it", "verify": "true"},
oracle_snapshot=_snapshot("oracle"),
repo=tmp_path / "repo",
task_sha=SHA,
graph_snapshot=_snapshot("graph"),
graph_snapshot_error=None,
asset_snapshot=_snapshot("asset"),
asset_snapshot_error=None,
args=SimpleNamespace(
model="gpt-5.6-sol", effort="xhigh", timeout=60, claude_bin="claude",
base_url=None, auth_token=None, permission_mode=None, arms=["review"],
proposer_model=None, outage_streak=5, runs=1, workers=1,
),
out_dir=tmp_path / "out",
ce_plugin_snapshot=None,
trees_dir=tmp_path / "trees",
bwrap_bin=Path("/bin/true"),
runtime_mounts=(),
candidate_overlay=None,
overlay_digest=None,
sandbox_backend="test-double",
clone_template=None,
sanitized_head=SHA,
)
(tmp_path / "out").mkdir(exist_ok=True)
(tmp_path / "trees").mkdir(exist_ok=True)
(tmp_path / "private").mkdir(exist_ok=True)
# run_cell records review_artifact only when the review source exists, and
# reuse now requires it - a scored review with no artifact is a claim about
# evidence rather than the evidence. Production writes this file; the
# fixture has to as well, or the emitted row is one production never emits.
review_dir = tmp_path / "private" / "review-output"
review_dir.mkdir(exist_ok=True)
(review_dir / "review-output.json").write_text('{"schema_version": 1, "verdict": "approve", "findings": []}')
return runner.run_cell(ctx, 0, "review")
def _expectation(**overrides: Any) -> ComparatorReuseExpectation:
"""Bindings from the sweep's own configuration, not copied out of the row.
Copying the emitted values back in would make producer and consumer agree
because the test arranged it, which is the blind spot being closed.
"""
binding = TaskReuseBinding(
task_base_sha=SHA,
task_prompt_digest=runner.hashlib.sha256(b"review it").hexdigest(),
oracle_digest="oracle-content",
oracle_command_digest="oracle-command",
oracle_manifest_digest="oracle-manifest",
task_asset_manifest_digest="asset-manifest",
sandbox_dependency_manifest_digest="asset-dep-manifest",
)
values: dict[str, Any] = dict(
model="gpt-5.6-sol",
effort="xhigh",
sandbox_backend="test-double",
runtime_digest="runtime-digest",
now=datetime.now(UTC),
max_age=timedelta(days=90),
tasks={TASK_ID: binding},
skill_digests={"review": "skill-digest"},
ce_plugin_version=None,
ce_plugin_manifest_digest=None,
)
values.update(overrides)
return ComparatorReuseExpectation(**values)
def test_a_row_the_runner_emitted_survives_serialization_and_qualifies(
emitted_row: dict[str, Any], tmp_path: Path
) -> None:
"""The producer/consumer contract, end to end through the real writer."""
results = _write_like_the_sweep(tmp_path, emitted_row)
rows = load_result_rows(results)
assert len(rows) == 1, "the production row must survive the reader"
assert row_is_reusable_comparator(rows[0], _expectation()) is True
def test_a_changed_binding_rejects_the_same_emitted_row(
emitted_row: dict[str, Any], tmp_path: Path
) -> None:
"""Fails closed on drift, so the positive case is not vacuous."""
row = load_result_rows(_write_like_the_sweep(tmp_path, emitted_row))[0]
binding = TaskReuseBinding(
task_base_sha=SHA,
task_prompt_digest=runner.hashlib.sha256(b"review it").hexdigest(),
oracle_digest="oracle-content",
oracle_command_digest="oracle-command",
oracle_manifest_digest="oracle-manifest",
task_asset_manifest_digest="asset-manifest",
sandbox_dependency_manifest_digest="DIFFERENT-dependencies",
)
assert row_is_reusable_comparator(row, _expectation(tasks={TASK_ID: binding})) is False

View file

@ -1,59 +0,0 @@
import hashlib
import json
from pathlib import Path
import yaml
from workflow_bench.oracle_assets import review_case_setup_command
BENCH_ROOT = Path(__file__).parents[1] / "workflow_bench"
def test_review_corpus_is_immutable_and_task_bound():
manifest = json.loads((BENCH_ROOT / "review_cases" / "manifest.json").read_text())
tasks = yaml.safe_load((BENCH_ROOT / "tasks.review.scenarios.yaml").read_text())["tasks"]
by_id = {task["id"]: task for task in tasks}
assert len(manifest["cases"]) >= 6
assert sum(case["id"].endswith("-defect") for case in manifest["cases"]) >= 4
assert sum(case["id"].endswith("-clean") for case in manifest["cases"]) >= 2
assert set(by_id) == {case["id"] for case in manifest["cases"]}
for case in manifest["cases"]:
assert len(case["base_sha"]) == len(case["head_sha"]) == 40
assert len(case["human_verification_commit"]) == 40
patch = BENCH_ROOT / "review_cases" / case["patch"]
assert case["patch"]
assert "defect" not in case["patch"]
assert "clean" not in case["patch"]
assert hashlib.sha256(patch.read_bytes()).hexdigest() == case["patch_sha256"]
task = by_id[case["id"]]
assert task["ref"] == case["base_sha"]
assert task["sandbox_copy"] == [f"eval/workflow_bench/review_cases/{patch.name}"]
assert task["setup"] == review_case_setup_command(patch.name)
assert any(
dep.get("source") == "gitnexus-shared/dist" and dep.get("target") == "gitnexus-shared/dist"
for dep in task["sandbox_dependencies"]
)
def test_hidden_labels_are_not_recoverable_from_visible_task_input():
tasks_path = BENCH_ROOT / "tasks.review.scenarios.yaml"
tasks = yaml.safe_load(tasks_path.read_text())["tasks"]
for task in tasks:
visible = json.dumps(
{
"prompt": task["prompt"],
"setup": task["setup"],
"sandbox_copy": task["sandbox_copy"],
},
sort_keys=True,
)
assert "review-labels.json" not in visible
assert "-defect" not in visible
assert "-clean" not in visible
for oracle_file in task["oracle"]["files"]:
assert oracle_file["source"] not in visible
assert oracle_file["target"] == "review-labels.json"

View file

@ -1,357 +0,0 @@
import json
from dataclasses import replace
from pathlib import Path
import pytest
from workflow_bench.oracle_assets import OracleFileSnapshot, TaskOracleSnapshot
from workflow_bench.review_scoring import (
ExpectedFinding,
ReviewFinding,
expected_findings,
parse_review_output,
score_review,
)
@pytest.mark.parametrize("noise", [False, True])
def test_complete_misses_are_measured_zero(noise):
actual = (ReviewFinding("noise", "low", "other.py", 1, 1, "style", "s", "e", "r", False),) if noise else ()
score = score_review("comment" if noise else "approve", actual, (expected(),))
assert score["f1"] == score["weighted_f1"] == 0
def test_downgraded_blocker_loses_weight_and_blocker_credit():
actual = ReviewFinding("a", "low", "src/api.ts", 20, 20, "correctness", "s", "e", "r", False)
score = score_review("comment", (actual,), (expected(),))
assert score["weighted_recall"] == 0.2
assert score["blocker_recall"] == 0
assert score["verdict_correct"] is False
@pytest.mark.parametrize("size", [2, 17, 100])
def test_maximum_matching_at_every_supported_size(size):
a = ReviewFinding("a", "high", "src/api.ts", 1, 1, "a", "s", "e", "r", True)
actual = [a, replace(a, finding_id="b", line=10, end_line=10, category="b")]
labels = [
expected(finding_id="broad", line_start=1, line_end=10, category="a"),
expected(finding_id="tight", line_start=1, line_end=1, category="b"),
]
for i in range(2, size):
actual.append(replace(a, finding_id=str(i), path=f"{i}.py"))
labels.append(expected(finding_id=str(i), path=f"{i}.py", line_start=1, line_end=1))
for findings in (actual, list(reversed(actual))):
for expected_labels in (labels, list(reversed(labels))):
assert score_review("request_changes", findings, expected_labels)["true_positives"] == size
def test_dense_matching_handles_the_full_finding_limit():
a = ReviewFinding("a", "high", "src/api.ts", 20, 20, "correctness", "s", "e", "r", True)
actual = [replace(a, finding_id=str(i)) for i in range(100)]
labels = [expected(finding_id=str(i)) for i in range(100)]
assert score_review("request_changes", actual, labels)["true_positives"] == 100
@pytest.mark.parametrize("large_side", ["actual", "expected"])
def test_maximum_matching_with_asymmetric_large_inputs(large_side):
a = ReviewFinding("a", "high", "src/api.ts", 1, 1, "a", "s", "e", "r", True)
actual = [a, replace(a, finding_id="b", line=10, end_line=10, category="b")]
labels = [
expected(finding_id="broad", line_start=1, line_end=10, category="a"),
expected(finding_id="tight", line_start=1, line_end=1, category="b"),
]
for i in range(15):
if large_side == "actual":
actual.append(replace(a, finding_id=str(i), path=f"extra-{i}.py"))
else:
labels.append(expected(finding_id=str(i), path=f"extra-{i}.py"))
assert score_review("request_changes", actual, labels)["true_positives"] == 2
def finding(**overrides):
values = {
"id": "actual-1",
"severity": "high",
"path": "src/api.ts",
"line": 20,
"end_line": 24,
"category": "correctness",
"scenario": "A missing guard lets an invalid request reach the sink.",
"evidence": "The changed call at line 20 bypasses validate().",
"recommendation": "Restore validation before the call.",
"blocking": True,
}
values.update(overrides)
return values
def expected(**overrides):
values = {
"finding_id": "expected-1",
"severity": "high",
"path": "src/api.ts",
"line_start": 18,
"line_end": 22,
"category": "correctness",
}
values.update(overrides)
return ExpectedFinding(**values)
def test_parse_review_output_requires_the_strict_schema(tmp_path: Path):
output = tmp_path / "review-output.json"
output.write_text(
json.dumps(
{
"schema_version": 1,
"verdict": "request_changes",
"findings": [finding()],
}
)
)
verdict, findings = parse_review_output(output)
assert verdict == "request_changes"
assert findings[0].path == "src/api.ts"
assert findings[0].blocking is True
@pytest.mark.parametrize(
"document, message",
[
({"schema_version": 1, "verdict": "approve", "findings": [finding()]}, "approve"),
(
{
"schema_version": 1,
"verdict": "request_changes",
"findings": [finding(blocking=False)],
},
"blocking",
),
(
{
"schema_version": 1,
"verdict": "comment",
"findings": [finding(path="../escape.ts")],
},
"repository-relative",
),
],
)
def test_parse_review_output_rejects_incoherent_or_unsafe_documents(tmp_path: Path, document, message):
output = tmp_path / "review-output.json"
output.write_text(json.dumps(document))
with pytest.raises(ValueError, match=message):
parse_review_output(output)
def test_expected_findings_are_loaded_from_hidden_snapshot_only():
payload = json.dumps(
{
"schema_version": 1,
"findings": [
{
"id": "hidden-1",
"severity": "critical",
"path": "src/auth.ts",
"line_start": 40,
"line_end": 44,
"category": "security",
}
],
}
).encode()
snapshot = TaskOracleSnapshot(
command="true",
command_digest="command",
manifest_digest="manifest",
digest="all",
files=(
OracleFileSnapshot(
target="review-labels.json",
payload=payload,
sha256="payload",
),
),
)
assert expected_findings(snapshot)[0].finding_id == "hidden-1"
def test_score_review_matches_by_path_and_overlapping_range():
actual = (
ReviewFinding(
finding_id="actual-1",
severity="high",
path="src/api.ts",
line=20,
end_line=24,
category="correctness",
scenario="scenario",
evidence="evidence",
recommendation="fix",
blocking=True,
),
ReviewFinding(
finding_id="noise",
severity="low",
path="src/other.ts",
line=1,
end_line=1,
category="style",
scenario="noise",
evidence="noise",
recommendation="noise",
blocking=False,
),
)
score = score_review("request_changes", actual, (expected(),))
assert score["true_positives"] == 1
assert score["false_positives"] == 1
assert score["false_negatives"] == 0
assert score["recall"] == 1
assert score["precision"] == 0.5
assert score["blocker_recall"] == 1
assert score["verdict_correct"] is True
def test_score_review_is_independent_of_finding_list_order():
expected_labels = (
expected(finding_id="broad", line_start=1, line_end=10, category="a"),
expected(finding_id="tight", line_start=5, line_end=5, category="b"),
)
first = ReviewFinding(
finding_id="a",
severity="high",
path="src/api.ts",
line=5,
end_line=5,
category="a",
scenario="s",
evidence="e",
recommendation="r",
blocking=True,
)
second = ReviewFinding(
finding_id="b",
severity="high",
path="src/api.ts",
line=1,
end_line=1,
category="b",
scenario="s",
evidence="e",
recommendation="r",
blocking=True,
)
forward = score_review("request_changes", (first, second), expected_labels)
reverse = score_review("request_changes", (second, first), expected_labels)
assert forward["true_positives"] == reverse["true_positives"]
assert forward["false_positives"] == reverse["false_positives"]
assert forward["false_negatives"] == reverse["false_negatives"]
assert forward["weighted_f1"] == reverse["weighted_f1"]
def test_score_review_prefers_maximum_cardinality_over_greedy_category_match():
expected_labels = (
expected(finding_id="broad", line_start=1, line_end=10, category="a"),
expected(finding_id="tight", line_start=1, line_end=1, category="b"),
)
actual = (
ReviewFinding(
finding_id="actual-1",
severity="high",
path="src/api.ts",
line=1,
end_line=1,
category="a",
scenario="s",
evidence="e",
recommendation="r",
blocking=True,
),
ReviewFinding(
finding_id="actual-2",
severity="high",
path="src/api.ts",
line=10,
end_line=10,
category="b",
scenario="s",
evidence="e",
recommendation="r",
blocking=True,
),
)
score = score_review("request_changes", actual, expected_labels)
assert score["true_positives"] == 2
assert score["false_positives"] == 0
assert score["false_negatives"] == 0
def test_clean_control_rewards_an_empty_approval_and_penalizes_noise():
clean = score_review("approve", (), ())
noisy = score_review(
"comment",
(
ReviewFinding(
finding_id="noise",
severity="medium",
path="src/ok.ts",
line=1,
end_line=1,
category="correctness",
scenario="noise",
evidence="noise",
recommendation="noise",
blocking=False,
),
),
(),
)
assert clean["weighted_f1"] is None
assert clean["precision"] is None
assert clean["recall"] is None
assert clean["clean_pass"] is True
assert clean["verdict_correct"] is True
assert noisy["false_positives"] == 1
assert noisy["weighted_precision"] == 0
assert noisy["recall"] is None
assert noisy["clean_pass"] is False
assert noisy["verdict_correct"] is False
def test_parse_review_output_names_the_actual_failure(tmp_path: Path):
"""One message per cause.
Folding empty, malformed and encoding failures together makes a sandbox that
left the artifact at 0 bytes indistinguishable from an encoding fault: every
such cell reports "not valid UTF-8 JSON". A file the agent never created
escaped that fold — lstat sat outside the try, so it raised
FileNotFoundError — but only as a bare OSError, naming no cause at all.
"""
missing = tmp_path / "never-written.json"
with pytest.raises(ValueError, match="was never written"):
parse_review_output(missing)
empty = tmp_path / "empty.json"
empty.touch()
with pytest.raises(ValueError, match="is empty"):
parse_review_output(empty)
not_utf8 = tmp_path / "latin1.json"
not_utf8.write_bytes(b'{"verdict": "\xff\xfe"}')
with pytest.raises(ValueError, match="not valid UTF-8"):
parse_review_output(not_utf8)
prose = tmp_path / "prose.json"
prose.write_text("Here is my review of the changes.", encoding="utf-8")
with pytest.raises(ValueError, match="not valid JSON"):
parse_review_output(prose)

View file

@ -2,19 +2,12 @@
import hashlib import hashlib
import json import json
import shutil
import subprocess
from contextlib import nullcontext
from pathlib import Path
from types import SimpleNamespace
import pytest import pytest
from workflow_bench import proposer_sandbox, runner, runner_artifacts, runner_sessions from workflow_bench import runner, runner_artifacts, runner_sessions
from workflow_bench.evolution import skill_fingerprint from workflow_bench.evolution import skill_fingerprint
from workflow_bench.oracle_assets import review_case_setup_command
from workflow_bench.process_control import ManagedProcessError, ManagedProcessResult from workflow_bench.process_control import ManagedProcessError, ManagedProcessResult
from workflow_bench.proposer_sandbox import SandboxError
def _report(**overrides) -> str: def _report(**overrides) -> str:
@ -282,16 +275,6 @@ def test_phase_workspace_ignores_claude_sandbox_bootstrap_noise(tmp_path):
(tmp_path / ".claude" / "commands").mkdir(parents=True) (tmp_path / ".claude" / "commands").mkdir(parents=True)
(tmp_path / ".claude" / ".cc-writes").write_text("{}") (tmp_path / ".claude" / ".cc-writes").write_text("{}")
(tmp_path / ".env").write_text("") (tmp_path / ".env").write_text("")
(tmp_path / ".bash_profile").write_text("")
(tmp_path / ".bashrc").write_text("")
(tmp_path / ".gitconfig").write_text("")
(tmp_path / ".idea").mkdir()
(tmp_path / ".profile").write_text("")
(tmp_path / ".ripgreprc").write_text("")
(tmp_path / ".vscode").mkdir()
(tmp_path / ".zprofile").write_text("")
(tmp_path / ".zshrc").write_text("")
(tmp_path / "scripts").write_text("")
(tmp_path / ".env.development.local").write_text("") (tmp_path / ".env.development.local").write_text("")
(tmp_path / ".npmrc").write_text("") (tmp_path / ".npmrc").write_text("")
(tmp_path / "package.json").write_text("{}") (tmp_path / "package.json").write_text("{}")
@ -303,30 +286,6 @@ def test_phase_workspace_ignores_claude_sandbox_bootstrap_noise(tmp_path):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact) runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_records_a_root_scripts_symlink(tmp_path):
target = tmp_path / "helper.py"
target.write_text("planted\n")
before = runner_artifacts.workspace_snapshot(tmp_path)
(tmp_path / "scripts").symlink_to(target)
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
with pytest.raises(ValueError, match="unauthorized workspace path"):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_still_rejects_writes_under_scripts(tmp_path):
scripts = tmp_path / "scripts"
scripts.mkdir()
before = runner_artifacts.workspace_snapshot(tmp_path)
(scripts / "helper.py").write_text("planted\n")
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
with pytest.raises(ValueError, match="unauthorized workspace path"):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_still_rejects_a_genuinely_unauthorized_change(tmp_path): def test_phase_workspace_still_rejects_a_genuinely_unauthorized_change(tmp_path):
# The bootstrap-noise exclusion must stay narrow: an actual source-file # The bootstrap-noise exclusion must stay narrow: an actual source-file
# edit outside the allowed artifact still has to be caught. # edit outside the allowed artifact still has to be caught.
@ -422,682 +381,3 @@ def test_phase_workspace_still_sees_writes_under_a_pre_existing_nested_claude_di
with pytest.raises(ValueError, match="unauthorized workspace path"): with pytest.raises(ValueError, match="unauthorized workspace path"):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact) runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def _cell_context(tmp_path, **overrides):
"""A TaskCellContext whose per-task inputs are all present and valid."""
snapshot = SimpleNamespace(
digest="asset-digest",
manifest_digest="asset-manifest",
dependency_content_digest="dep-content",
dependency_manifest_digest="dep-manifest",
)
graph = SimpleNamespace(
digest="graph-digest",
manifest_digest="graph-manifest",
materialize=lambda *_a, **_k: None,
)
oracle = SimpleNamespace(
digest="oracle-digest",
command_digest="oracle-command",
manifest_digest="oracle-manifest",
)
fields = {
"task": {"id": "task-a", "class": "demo", "prompt": "do the thing"},
"oracle_snapshot": oracle,
"repo": tmp_path / "repo",
"task_sha": "a" * 40,
"graph_snapshot": graph,
"graph_snapshot_error": None,
"asset_snapshot": snapshot,
"asset_snapshot_error": None,
"args": SimpleNamespace(
claude_bin="claude",
model="pinned-model",
proposer_model=None,
effort="xhigh",
auth_token=None,
),
"out_dir": tmp_path / "out",
"ce_plugin_snapshot": None,
"trees_dir": tmp_path / "trees",
"bwrap_bin": tmp_path / "bwrap",
"runtime_mounts": (),
"candidate_overlay": None,
"overlay_digest": None,
}
fields.update(overrides)
fields["out_dir"].mkdir(parents=True, exist_ok=True)
return runner.TaskCellContext(**fields)
def _stub_cell_dependencies(monkeypatch, tmp_path):
"""Replace everything a cell shells out to, so only its own logic runs.
Returns the clone it will hand out and the list its teardown appends to.
"""
removed: list[Path] = []
worktree = tmp_path / "clone"
worktree.mkdir()
monkeypatch.setattr(runner, "make_worktree", lambda *_a, **_k: worktree)
monkeypatch.setattr(runner, "sanitize_clone_for_hidden_oracles", lambda *_a, **_k: "b" * 40)
monkeypatch.setattr(runner, "stage_task_assets", lambda *_a, **_k: [])
monkeypatch.setattr(runner, "isolated_gitnexus_registry_mount", lambda *_a, **_k: None)
monkeypatch.setattr(runner, "ce_plugin_mounts_for_arm", lambda *_a, **_k: [])
monkeypatch.setattr(runner, "ce_plugin_dir_for_arm", lambda *_a, **_k: None)
monkeypatch.setattr(runner, "prepare_sandbox", lambda **_k: nullcontext(SimpleNamespace(run=None)))
monkeypatch.setattr(runner, "skill_fingerprint", lambda *_a, **_k: "skill-digest")
monkeypatch.setattr(runner, "require_skill_fingerprint", lambda *_a, **_k: None)
monkeypatch.setattr(runner, "_sandbox_git", lambda *_a, **_k: "c" * 40)
monkeypatch.setattr(runner, "implementation_diff_digest", lambda *_a, **_k: "")
monkeypatch.setattr(runner, "_prepare_untracked_for_diff", lambda *_a, **_k: None)
monkeypatch.setattr(runner, "diff_churn", lambda *_a, **_k: {})
monkeypatch.setattr(runner, "enforce_work_evidence", lambda *_a, **_k: None)
monkeypatch.setattr(runner, "capture_patch", lambda *_a, **_k: b"diff")
monkeypatch.setattr(runner, "run_arm", lambda *_a, **_k: {"resolved": True, "ok": True, "error_kind": None})
monkeypatch.setattr(runner, "remove_clone", lambda path: removed.append(path))
return worktree, removed
def test_run_cell_returns_a_row_bound_to_its_task_and_snapshots(monkeypatch, tmp_path):
_, removed = _stub_cell_dependencies(monkeypatch, tmp_path)
record = runner.run_cell(_cell_context(tmp_path), 2, "workflow")
assert record["resolved"] is True
assert record["error_kind"] is None
# The row has to carry its own coordinates: once cells stop running in a
# predictable order, position in results.jsonl identifies nothing.
assert record["task"] == "task-a"
assert record["arm"] == "workflow"
assert record["run"] == 2
assert record["task_asset_snapshot_digest"] == "asset-digest"
assert record["sanitized_graph_snapshot_digest"] == "graph-digest"
assert record["oracle_digest"] == "oracle-digest"
assert removed == [tmp_path / "clone"]
@pytest.mark.parametrize(
"failure",
[
ManagedProcessError(
["setup"],
ManagedProcessResult(
state="timeout",
returncode=-15,
stdout_tail="",
stderr_tail="",
duration_s=1.0,
),
),
SandboxError("sandbox refused"),
OSError("disk went away"),
RuntimeError("overlay drifted"),
ValueError("bad binding"),
],
ids=["managed-process", "sandbox", "os", "runtime", "value"],
)
def test_run_cell_records_an_expected_failure_and_still_removes_its_clone(monkeypatch, tmp_path, failure):
_, removed = _stub_cell_dependencies(monkeypatch, tmp_path)
def explode(*_args, **_kwargs):
raise failure
monkeypatch.setattr(runner, "run_arm", explode)
record = runner.run_cell(_cell_context(tmp_path), 0, "workflow")
assert record["error_kind"] == "infra-error"
assert record["resolved"] is False
# A cell owns its clone for its whole lifetime; the sweep has no other
# chance to reclaim it, so the finally must survive every expected failure.
assert removed == [tmp_path / "clone"]
def test_run_cell_redacts_the_auth_token_from_the_failure_it_prints(monkeypatch, tmp_path, capsys):
_stub_cell_dependencies(monkeypatch, tmp_path)
secret = "sk-ant-not-a-real-key"
def explode(*_args, **_kwargs):
raise ManagedProcessError(
["claude"],
ManagedProcessResult(
state="exited",
returncode=1,
stdout_tail="",
stderr_tail=f"ANTHROPIC_API_KEY={secret}",
duration_s=1.0,
),
)
monkeypatch.setattr(runner, "run_arm", explode)
context = _cell_context(tmp_path)
context.args.auth_token = secret
# ManagedProcessError stringifies up to 1000 raw bytes of stderr_tail, and
# this line streams live into the CI log now that the sweep's stdout is
# echoed. results.jsonl already redacts the same field.
runner.run_cell(context, 0, "workflow")
assert secret not in capsys.readouterr().out
def test_run_cell_lets_an_unexpected_failure_escape_rather_than_scoring_it(monkeypatch, tmp_path):
_, removed = _stub_cell_dependencies(monkeypatch, tmp_path)
def explode(*_args, **_kwargs):
raise KeyError("harness bug")
monkeypatch.setattr(runner, "run_arm", explode)
# A harness bug recorded as an ordinary infra-error would be averaged into
# the evidence and counted toward the outage breaker. It must crash instead.
with pytest.raises(KeyError):
runner.run_cell(_cell_context(tmp_path), 0, "workflow")
assert removed == [tmp_path / "clone"]
def test_run_cell_reports_a_cleanup_failure_over_its_primary_outcome(monkeypatch, tmp_path):
_stub_cell_dependencies(monkeypatch, tmp_path)
def refuse(_path):
raise OSError("clone is busy")
monkeypatch.setattr(runner, "remove_clone", refuse)
record = runner.run_cell(_cell_context(tmp_path), 1, "workflow")
assert record["error_kind"] == "cleanup-failure"
assert record["resolved"] is False
assert "primary=None" in record["error_detail"]
assert "clone is busy" in record["error_detail"]
def _git(repo, *args):
return subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, text=True)
def test_run_cell_runs_the_arm_against_a_copy_of_the_clone_template(monkeypatch, tmp_path):
"""run_cell must copy the template, never re-clone.
run_cell takes the clone-template branch on essentially every multi-cell
sweep: it copies a pre-sanitized template rather than paying `git clone
--no-local` plus repack/prune/fsck per cell. Asserting on a copy the test
makes itself proves nothing about that branch — the clone the arm receives
is what has to come from the template, carrying the template's sanitized
HEAD rather than a recomputed one.
"""
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "--quiet")
_git(repo, "checkout", "--quiet", "-b", "main")
(repo / "from-template.txt").write_text("sanitized\n")
_git(repo, "add", "-A")
_git(repo, "-c", "user.name=test", "-c", "user.email=test@invalid", "commit", "--quiet", "-m", "base")
sha = _git(repo, "rev-parse", "HEAD").stdout.strip()
trees = tmp_path / "trees"
trees.mkdir()
template = runner.make_worktree(repo, sha, trees)
template_head = _git(template, "rev-parse", "HEAD").stdout.strip()
_stub_cell_dependencies(monkeypatch, tmp_path)
def fail_if_recloned(*_args, **_kwargs):
raise AssertionError("clone template present: run_cell must not re-clone")
monkeypatch.setattr(runner, "make_worktree", fail_if_recloned)
monkeypatch.setattr(runner, "sanitize_clone_for_hidden_oracles", fail_if_recloned)
seen: dict[str, object] = {}
def record_arm(_arm, _task, worktree, _args, **_kwargs):
seen["worktree"] = worktree
seen["head"] = _git(worktree, "rev-parse", "HEAD").stdout.strip()
seen["content"] = (worktree / "from-template.txt").read_text()
# The copy is a private checkout: what the cell writes must not reach
# the template the other cells of this task still copy from.
(worktree / "from-template.txt").write_text("cell-local\n")
return {"resolved": True, "ok": True, "error_kind": None}
monkeypatch.setattr(runner, "run_arm", record_arm)
runner.run_cell(
_cell_context(tmp_path, clone_template=template, sanitized_head=template_head),
0,
"workflow",
)
assert seen["content"] == "sanitized\n"
assert seen["head"] == template_head
assert seen["worktree"] != template
assert (template / "from-template.txt").read_text() == "sanitized\n"
def test_run_cell_does_not_mask_the_staged_review_patch_before_setup(monkeypatch, tmp_path):
"""Review setup applies a patch staged under eval/workflow_bench.
Overlaying the empty oracle mask on that path is the CI abort:
`git apply` dies with `can't open patch`. The staged copy must stay
visible to sandboxed setup, then be gone before the model starts.
"""
worktree, _ = _stub_cell_dependencies(monkeypatch, tmp_path)
patch = worktree / "eval" / "workflow_bench" / "review_cases" / "pr-2718.patch"
patch.parent.mkdir(parents=True)
patch.write_text("diff --git a/visible.py b/visible.py\n")
captured: dict[str, object] = {}
def fake_prepare(**kwargs):
captured["mounts"] = kwargs.get("read_only_mounts", [])
def run(_command, **_kwargs):
leftover = worktree / "eval" / "workflow_bench"
if leftover.exists():
shutil.rmtree(leftover)
return SimpleNamespace(ok=True)
return nullcontext(SimpleNamespace(run=run))
monkeypatch.setattr(runner, "prepare_sandbox", fake_prepare)
context = _cell_context(
tmp_path,
task={
"id": "review-pr-2718-defect",
"class": "review-defect",
"prompt": "review the local diff",
"setup": review_case_setup_command("pr-2718.patch"),
},
)
record = runner.run_cell(context, 0, "workflow")
assert record.get("error_kind") is None
targets = [getattr(mount, "target", None) for mount in captured["mounts"] if mount is not None]
assert not any(target and "eval/workflow_bench" in str(target) for target in targets)
assert not (worktree / "eval" / "workflow_bench").exists()
def test_run_cell_fails_closed_when_setup_leaves_the_hidden_harness(monkeypatch, tmp_path):
worktree, _ = _stub_cell_dependencies(monkeypatch, tmp_path)
leftover = worktree / "eval" / "workflow_bench" / "review_cases"
leftover.mkdir(parents=True)
(leftover / "pr-2718.patch").write_text("diff\n")
def fake_prepare(**kwargs):
return nullcontext(SimpleNamespace(run=lambda *_a, **_k: SimpleNamespace(ok=True)))
monkeypatch.setattr(runner, "prepare_sandbox", fake_prepare)
context = _cell_context(
tmp_path,
task={
"id": "review-pr-2718-defect",
"class": "review-defect",
"prompt": "review the local diff",
"setup": review_case_setup_command("pr-2718.patch"),
},
)
record = runner.run_cell(context, 0, "workflow")
assert record["error_kind"] == "infra-error"
assert "hidden harness visible" in str(record["error_detail"])
def test_run_cell_fails_closed_when_a_per_task_snapshot_never_materialized(tmp_path):
# The snapshots are prepared once per task, before any cell. If that failed,
# every cell of the task has to record it rather than run against nothing.
context = _cell_context(tmp_path, asset_snapshot=None, asset_snapshot_error=OSError("no assets"))
record = runner.run_cell(context, 0, "workflow")
assert record["error_kind"] == "infra-error"
assert "no assets" in str(record["error_detail"])
def _progress():
"""Collector for what a sweep started and kept, readable after it raises."""
return SimpleNamespace(started=[], kept=[], streak=0, tripped=False)
def _sweep(cells, *, workers, run, outage_limit=5, streak=0, into=None):
"""Drive sweep_task_cells, recording what it started and kept.
Pass ``into`` a ``_progress()`` when the sweep is expected to raise: the
collector survives the exception, the return value does not.
"""
result = _progress() if into is None else into
result.streak, result.tripped = runner.sweep_task_cells(
cells,
workers=workers,
run=run,
on_start=lambda run_idx, arm: result.started.append((run_idx, arm)),
on_record=lambda run_idx, arm, _record: result.kept.append((run_idx, arm)),
outage_streak=streak,
outage_limit=outage_limit,
)
return result
def _row(error_kind=None):
return {"resolved": error_kind is None, "error_kind": error_kind}
CELLS = [(run_idx, arm) for run_idx in range(3) for arm in ("workflow", "candidate_workflow")]
@pytest.mark.parametrize("workers", [1, 3, 8])
@pytest.mark.parametrize("primary", ["review-evidence-invalid", "skill-not-invoked", "session-error"])
def test_unusable_review_evidence_stops_a_54_cell_sweep(workers, primary):
cells = [(i, "review") for i in range(54)]
result = _sweep(
cells,
workers=workers,
run=lambda *_: {
"resolved": False,
"error_kind": primary,
"review_evidence_valid": False,
},
)
assert result.tripped
assert 5 <= len(result.started) <= 5 + workers - 1
assert result.kept == result.started
def test_measured_review_miss_resets_the_outage_streak():
result = _sweep(
CELLS,
workers=1,
streak=4,
run=lambda *_: {
"resolved": False,
"error_kind": "oracle-failed",
"review_evidence_valid": True,
"review_weighted_f1": 0.0,
},
)
assert result.streak == 0 and not result.tripped
def test_invalid_review_with_primary_skill_error_is_excluded_from_aggregation():
result = runner.aggregate(
[
{
"resolved": False,
"error_kind": "skill-not-invoked",
"review_evidence_valid": False,
}
]
)
assert result["excluded_runs"] == 1 and result["valid_runs"] == 0
def test_sweep_keeps_rows_in_submission_order_whatever_order_they_finish():
# Cells finish in whatever order the machine allows, but a wave is folded
# in submission order — the outage streak counts consecutive failures, and
# "consecutive" in completion order would make the trip point flaky.
import threading
first_wave = CELLS[:3]
rendezvous = threading.Barrier(3, timeout=10)
release_first = threading.Event()
fast_finished = threading.Event()
finished: list[tuple[int, str]] = []
result: list[SimpleNamespace] = []
def run(run_idx, arm):
cell = (run_idx, arm)
if cell in first_wave:
rendezvous.wait()
if cell == first_wave[0]:
release_first.wait(timeout=10)
else:
finished.append(cell)
if len(finished) == 2:
fast_finished.set()
return _row()
sweep = threading.Thread(target=lambda: result.append(_sweep(CELLS, workers=3, run=run)))
sweep.start()
try:
assert fast_finished.wait(timeout=10)
assert first_wave[0] not in finished
assert set(finished) == set(first_wave[1:])
finally:
release_first.set()
sweep.join(timeout=10)
assert not sweep.is_alive()
assert result[0].kept == CELLS
assert result[0].started == CELLS
assert result[0].tripped is False
@pytest.mark.parametrize("workers", [1, 2, 3])
def test_sweep_trips_the_breaker_within_one_wave_of_the_serial_point(workers):
# Serial stops after the 5th consecutive systemic failure. Cells already in
# flight when the breaker trips cannot be recalled or erased from the
# evidence, so the overrun is bounded by the wave and every completed row
# is kept. Ten cells make the bound visible rather than hidden by the end.
long_task = [(run_idx, arm) for run_idx in range(5) for arm in ("workflow", "candidate_workflow")]
result = _sweep(long_task, workers=workers, run=lambda *_: _row("session-error"))
assert result.tripped is True
assert 5 <= len(result.started) <= 5 + workers - 1
assert result.kept == result.started
assert len(result.started) < len(long_task)
def test_sweep_reads_a_real_failure_as_signal_rather_than_an_outage():
# resolved=False with no systemic error_kind is the benchmark working, not
# the harness failing; it must reset the streak instead of tripping.
result = _sweep(CELLS, workers=3, run=lambda *_: {"resolved": False, "error_kind": None})
assert result.tripped is False
assert result.streak == 0
assert result.kept == CELLS
def test_sweep_surfaces_an_unexpected_worker_failure_instead_of_dropping_the_cell():
def run(run_idx, arm):
if (run_idx, arm) == (0, "candidate_workflow"):
raise KeyError("harness bug")
return _row()
# A Future holds its exception until read. Unread, this cell would vanish
# from the evidence with no crash and no row — fewer runs in an arm's
# aggregate, silently.
with pytest.raises(KeyError):
_sweep(CELLS, workers=3, run=run)
def test_sweep_runs_cells_of_a_wave_at_the_same_time():
import threading
barrier = threading.Barrier(3, timeout=10)
def run(run_idx, arm):
# Deadlocks unless all three cells of the wave are genuinely in flight
# together — a pool that serialised them would time out here.
barrier.wait()
return _row()
result = _sweep(CELLS, workers=3, run=run)
assert result.kept == CELLS
def test_sweep_of_one_worker_never_leaves_the_calling_thread():
import threading
caller = threading.current_thread()
seen: list[threading.Thread] = []
def run(run_idx, arm):
seen.append(threading.current_thread())
return _row()
# Ctrl-C reaches only the main thread, so the serial default has to stay on
# it: a cell on a worker thread is outside the reach of the cleanup that
# kills its sandboxed process tree.
_sweep(CELLS, workers=1, run=run)
assert seen == [caller] * len(CELLS)
@pytest.mark.parametrize("failure", [KeyError("harness bug"), SystemExit(97)])
def test_sweep_keeps_the_rows_of_cells_that_finished_beside_a_failing_one(failure):
def run(run_idx, arm):
if (run_idx, arm) == (0, "candidate_workflow"):
raise failure
return _row()
progress = _progress()
# The failing cell's two siblings completed and spent their budget before
# the harness bug surfaced. Reading the futures in order and raising on the
# first failure would drop their rows: money spent, no evidence written.
with pytest.raises(type(failure)):
_sweep(CELLS, workers=3, run=run, into=progress)
assert progress.kept == [(0, "workflow"), (1, "workflow")]
def test_sweep_hands_a_ctrl_c_back_without_waiting_for_the_running_cells(monkeypatch):
import threading
in_flight = threading.Barrier(3, timeout=10)
release = threading.Event()
finished: list[tuple[int, str]] = []
def run(run_idx, arm):
in_flight.wait()
release.wait(timeout=10)
finished.append((run_idx, arm))
return _row()
def interrupt_once_the_wave_is_running(_futures, *_args, **_kwargs):
# Stands in for the Ctrl-C an operator types mid-wave: an async
# KeyboardInterrupt is delivered to the main thread, which is the one
# blocked here waiting on the wave.
in_flight.wait()
raise KeyboardInterrupt
monkeypatch.setattr(runner, "wait", interrupt_once_the_wave_is_running)
try:
with runner.cancellation_scope(release), pytest.raises(KeyboardInterrupt):
_sweep(CELLS, workers=2, run=run)
# Cancellation releases active work before joining; no worker can
# outlive the assets the interrupted sweep is about to clean up.
assert set(finished) == set(CELLS[:2])
finally:
release.set()
def test_workers_is_bounded_at_both_ends_before_the_sweep_starts():
base = ["--tasks", "tasks.yaml", "--model", "pinned-model"]
assert runner.build_parser().parse_args(base).workers == 1
at_max = runner.build_parser().parse_args([*base, "--workers", str(runner.MAX_WORKERS)])
assert at_max.workers == runner.MAX_WORKERS
# A mistyped worker count has to fail at the command line: hours later it
# only shows up as timed-out sessions, which the promotion gate throws away.
for rejected in ("0", "-1", str(runner.MAX_WORKERS + 1)):
with pytest.raises(SystemExit):
runner.build_parser().parse_args([*base, "--workers", rejected])
def test_partial_wave_submission_preserves_rows_and_original_interruption(monkeypatch):
original = runner.ThreadPoolExecutor.submit
submissions = 0
def submit(pool, *args, **kwargs):
nonlocal submissions
submissions += 1
if submissions == 2:
raise KeyboardInterrupt("submission interrupted")
return original(pool, *args, **kwargs)
monkeypatch.setattr(runner.ThreadPoolExecutor, "submit", submit)
records = []
with pytest.raises(KeyboardInterrupt, match="submission interrupted"):
runner.sweep_task_cells(
CELLS,
workers=2,
run=lambda *_: _row(),
on_start=lambda *_: None,
on_record=lambda *record: records.append(record),
outage_streak=0,
outage_limit=5,
)
assert len(records) == 2
assert records[1][2]["error_kind"] == "cancelled"
@pytest.mark.parametrize("error_kind", ["infra-error", "cleanup-failure"])
def test_progress_line_reports_an_unmeasured_failure_as_unmeasured_not_as_free(error_kind):
dead = runner.infra_error_record(RuntimeError("bwrap died"))
dead["error_kind"] = error_kind
line = runner.cell_progress_line("task", "workflow", 0, dead)
# The 0.0s are placeholders for numbers no session ever produced; printed
# as numbers they read as a cell that ran instantly for free.
assert "cost=n/a" in line
assert "took=n/a" in line
assert f"error_kind={error_kind}" in line
# results.jsonl is promotion evidence — only the display changes.
assert dead["cost_usd"] == 0.0
assert dead["duration_s"] == 0.0
def test_progress_line_reports_the_numbers_a_real_run_measured():
line = runner.cell_progress_line(
"task",
"workflow",
1,
{
"resolved": True,
"input_tokens": 10,
"output_tokens": 2,
"cost_usd": 0.5,
"duration_s": 12.0,
"error_kind": None,
},
)
assert "cost=$0.5" in line
assert "took=12.0s" in line
assert "error_kind=none" in line
def test_claude_settings_allow_the_review_artifact_directory():
"""The second gate on the artifact path.
The bwrap bind is not the only thing that decides whether the agent can
write: the CLI applies this filesystem policy to its own tools, so a path
missing from allowWrite is unwritable however the mount is shaped. The
artifact lived under /workspace when this list was written, which is why
moving it out needed this entry and nothing caught the omission.
"""
settings = json.loads(proposer_sandbox.build_claude_settings(sandbox_enabled=True))
filesystem = settings["sandbox"]["filesystem"]
assert proposer_sandbox.SANDBOX_REVIEW_OUTPUT in filesystem["allowWrite"]
assert proposer_sandbox.SANDBOX_REVIEW_OUTPUT in filesystem["allowRead"]
assert filesystem["denyRead"] == ["/"]
def test_review_contract_tells_the_agent_the_writable_path():
prompt = runner.REVIEW_PROMPT.format(task="task text")
assert f"{runner.SANDBOX_REVIEW_OUTPUT}/{runner.REVIEW_OUTPUT}" in prompt
assert f"{runner.SANDBOX_WORKSPACE}/{runner.REVIEW_OUTPUT}" not in prompt
# The JSON shape survives .format() with its braces intact.
assert '{"schema_version":1' in prompt
artifact = f"{runner.SANDBOX_REVIEW_OUTPUT}/{runner.REVIEW_OUTPUT}"
assert runner.CE_REVIEW_PROMPT.format(task="task text").count(artifact) == 1
def test_enforce_phase_workspace_can_require_an_untouched_workspace(tmp_path):
(tmp_path / "tracked.py").write_text("original\n")
before = runner_artifacts.workspace_snapshot(tmp_path)
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=None)
(tmp_path / "tracked.py").write_text("the review edited the code it was reviewing\n")
with pytest.raises(ValueError, match="changed the read-only workspace"):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=None)

View file

@ -27,32 +27,6 @@ def test_prebuilt_graph_and_harness_assets_are_rejected(task):
sanitized_graph.validate_no_prebuilt_graph_assets(task) sanitized_graph.validate_no_prebuilt_graph_assets(task)
def test_review_case_patches_are_allowed_sandbox_copy():
sanitized_graph.validate_no_prebuilt_graph_assets(
{"sandbox_copy": ["eval/workflow_bench/review_cases/pr-2718.patch"]}
)
@pytest.mark.parametrize(
"task",
[
{"sandbox_copy": ["eval/workflow_bench"]},
{"sandbox_copy": ["eval/workflow_bench/evolve.py"]},
{
"sandbox_dependencies": [
{
"source": "eval/workflow_bench/review_cases/pr-2718.patch",
"target": "patch",
}
]
},
],
)
def test_non_corpus_harness_paths_stay_rejected(task):
with pytest.raises(SandboxError, match="prebuilt graph or harness"):
sanitized_graph.validate_no_prebuilt_graph_assets(task)
def test_graph_environment_is_offline_deterministic_and_ignores_target_gitignore(): def test_graph_environment_is_offline_deterministic_and_ignores_target_gitignore():
env = sanitized_graph._graph_environment() env = sanitized_graph._graph_environment()
@ -60,10 +34,6 @@ def test_graph_environment_is_offline_deterministic_and_ignores_target_gitignore
assert env["GITNEXUS_NO_GITIGNORE"] == "1" assert env["GITNEXUS_NO_GITIGNORE"] == "1"
assert env["GITNEXUS_WORKER_POOL_SIZE"] == "1" assert env["GITNEXUS_WORKER_POOL_SIZE"] == "1"
assert env["GITNEXUS_PARSE_CHUNK_CONCURRENCY"] == "1" assert env["GITNEXUS_PARSE_CHUNK_CONCURRENCY"] == "1"
assert env["GITNEXUS_WORKER_READY_TIMEOUT_MS"] == str(
sanitized_graph.GRAPH_WORKER_READY_TIMEOUT_MS
)
assert int(env["GITNEXUS_WORKER_READY_TIMEOUT_MS"]) >= 60_000
assert "ANTHROPIC_API_KEY" not in env assert "ANTHROPIC_API_KEY" not in env
@ -212,22 +182,6 @@ def test_prepare_sanitized_graph_builds_once_from_parentless_tree_and_caches_onl
assert removed == [seed] assert removed == [seed]
def test_prepare_sanitized_graph_requires_head_when_given_a_template(tmp_path: Path):
with pytest.raises(SandboxError, match="sanitized HEAD"):
sanitized_graph.prepare_sanitized_graph(
{},
repo=tmp_path,
resolved_sha="b" * 40,
parent=tmp_path,
cache=SimpleNamespace(), # type: ignore[arg-type]
claude_bin="claude",
bwrap_bin="bwrap",
runtime_mounts=(),
clone_template=tmp_path,
sanitized_head=None,
)
def test_graph_snapshot_rejects_arm_sanitization_identity_drift(tmp_path: Path): def test_graph_snapshot_rejects_arm_sanitization_identity_drift(tmp_path: Path):
assets = SimpleNamespace( assets = SimpleNamespace(
digest="digest", digest="digest",

View file

@ -1,372 +0,0 @@
"""Live progress reporting for long headless sessions.
Progress includes event metadata and bounded, redacted tool argument/result
previews. Model prose and raw event streams are never echoed. These tests pin
that boundary along with the signals that distinguish work from a wedged run.
"""
from __future__ import annotations
import io
import json
import time
from workflow_bench.runner_sessions import SessionProgress, neutralize_ci_log_text
def _drain_lines(stream: io.StringIO) -> list[str]:
return [line for line in stream.getvalue().splitlines() if line.strip()]
def _observe(progress: SessionProgress, chunk: bytes) -> None:
progress.observe(chunk)
progress._emit_pending()
def test_progress_bounds_unanswered_tools_and_undrained_messages() -> None:
progress = SessionProgress("bounded", stream=io.StringIO())
for index in range(2000):
progress.observe(
(
json.dumps(
{
"type": "assistant",
"message": {
"content": [
{"type": "tool_use", "id": str(index), "name": "Bash", "input": {"command": "true"}}
]
},
}
)
+ "\n"
).encode()
)
assert len(progress._pending_tools) <= 256
assert len(progress._pending_messages) <= 256
assert "1999" in progress._pending_tools
assert "0" not in progress._pending_tools
progress.observe(
(
json.dumps(
{
"type": "user",
"message": {
"content": [{"type": "tool_result", "tool_use_id": "1999", "content": "recent result"}]
},
}
)
+ "\n"
).encode()
)
progress._emit_pending()
assert "recent result" in progress._stream.getvalue()
assert "1999" not in progress._pending_tools
def test_progress_reports_bounded_redacted_tool_io_but_never_model_prose() -> None:
stream = io.StringIO()
progress = SessionProgress(
"gen 0 proposer",
stream=stream,
heartbeat_s=3600,
secrets=("SECRET-TOKEN-abc123",),
)
events = [
{"type": "system", "subtype": "init"},
{
"type": "assistant",
"message": {
"content": [
{"type": "text", "text": "SECRET-REASONING-abc123"},
{
"type": "tool_use",
"id": "t1",
"name": "Grep",
"input": {
"pattern": "TODO",
"path": "/workspace",
"token": "SECRET-TOKEN-abc123",
},
},
]
},
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "t1",
"is_error": False,
"content": "src/a.py:1: TODO " + "x" * 1000,
}
]
},
},
{"type": "result", "num_turns": 1, "is_error": False, "total_cost_usd": 1.5},
]
for event in events:
_observe(progress, (json.dumps(event) + "\n").encode())
output = stream.getvalue()
assert "SECRET-REASONING-abc123" not in output
assert "SECRET-TOKEN-abc123" not in output
assert "[REDACTED]" in output
assert "session initialized" in output
assert "turn 1 · Grep" in output
assert 'tool Grep input={"pattern":"TODO","path":"/workspace","token":"[REDACTED]"}' in output
assert "tool Grep result=ok output=" in output
assert "truncated" in output
assert "finished · 1 turns · ok · $1.50" in output
def test_progress_reports_errors_and_mcp_io_but_skips_other_tool_payloads() -> None:
stream = io.StringIO()
progress = SessionProgress("flow", stream=stream, heartbeat_s=3600)
events = [
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "m1",
"name": "mcp__gitnexus__query",
"input": {"search_query": "call resolution"},
},
{
"type": "tool_use",
"id": "e1",
"name": "Edit",
"input": {"file_path": "secret.py", "new_string": "do not log"},
},
]
},
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "m1",
"is_error": True,
"content": "repository is not indexed",
},
{
"type": "tool_result",
"tool_use_id": "e1",
"content": "edited secret.py",
},
]
},
},
]
for event in events:
_observe(progress, (json.dumps(event) + "\n").encode())
output = stream.getvalue()
assert 'tool mcp__gitnexus__query input={"search_query":"call resolution"}' in output
assert 'tool mcp__gitnexus__query result=error output="repository is not indexed"' in output
assert "do not log" not in output
assert "edited secret.py" not in output
def test_progress_distinguishes_mcp_semantic_errors_from_transport_success() -> None:
stream = io.StringIO()
progress = SessionProgress("flow", stream=stream, heartbeat_s=3600)
events = [
{
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"id": "m1",
"name": "mcp__gitnexus__impact",
"input": {"target": "missing", "direction": "upstream"},
}
]
},
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "m1",
"is_error": False,
"content": [
{
"type": "text",
"text": '{"error":"Target missing not found"}\n\n---\n**Next:** retry',
}
],
}
]
},
},
]
for event in events:
_observe(progress, (json.dumps(event) + "\n").encode())
assert "tool mcp__gitnexus__impact result=semantic-error" in stream.getvalue()
def test_progress_calls_out_api_retries_because_that_is_the_stuck_signature() -> None:
stream = io.StringIO()
progress = SessionProgress("proposer", stream=stream, heartbeat_s=3600)
event = {
"type": "system",
"subtype": "api_retry",
"attempt": 7,
"max_retries": 10,
"retry_delay_ms": 34199.87,
"error": "unknown",
}
_observe(progress, (json.dumps(event) + "\n").encode())
line = _drain_lines(stream)[-1]
assert "API retry 7/10 in 34s" in line
assert "no response from the model endpoint" in line
def test_progress_speaks_up_while_a_session_is_silent() -> None:
stream = io.StringIO()
with SessionProgress("proposer", stream=stream, heartbeat_s=0.05):
time.sleep(0.35)
heartbeats = [line for line in _drain_lines(stream) if "still running" in line]
assert heartbeats, "a silent session must still report that it is alive"
assert "0 turns" in heartbeats[0]
def test_progress_survives_partial_chunks_garbage_and_unbounded_lines() -> None:
stream = io.StringIO()
progress = SessionProgress("proposer", stream=stream, heartbeat_s=3600)
payload = json.dumps(
{"type": "assistant", "message": {"content": [{"type": "tool_use", "id": "t1", "name": "Bash"}]}}
).encode()
# An event split across reads, non-JSON noise, and a huge newline-free run.
_observe(progress, payload[:10])
_observe(progress, payload[10:] + b"\nnot json at all\n")
_observe(progress, b"x" * (4 * 1024 * 1024))
_observe(progress, b'\n{"type":"result","num_turns":2,"is_error":true}\n')
output = stream.getvalue()
assert "turn 1 · Bash" in output
assert "finished · 2 turns · error" in output
def test_progress_sanitizes_a_hostile_tool_name() -> None:
stream = io.StringIO()
progress = SessionProgress("proposer", stream=stream, heartbeat_s=3600)
event = {
"type": "assistant",
"message": {"content": [{"type": "tool_use", "id": "t1", "name": "Bash\nFAKE-LOG-LINE injected"}]},
}
_observe(progress, (json.dumps(event) + "\n").encode())
assert "FAKE-LOG-LINE" not in stream.getvalue()
assert len(_drain_lines(stream)) == 1
def test_progress_redacts_non_ascii_secrets_before_json_escaping() -> None:
stream = io.StringIO()
secret = "tokén-密码"
progress = SessionProgress("flow", stream=stream, heartbeat_s=3600, secrets=(secret,))
event = {
"type": "assistant",
"message": {
"content": [
{"type": "tool_use", "id": "t1", "name": "Grep", "input": {"token": secret}},
]
},
}
_observe(progress, (json.dumps(event, ensure_ascii=False) + "\n").encode())
output = stream.getvalue()
assert secret not in output
assert json.dumps(secret)[1:-1] not in output
assert "[REDACTED]" in output
def test_cell_failure_detail_line_explains_why_a_cell_failed() -> None:
from workflow_bench.runner import cell_failure_detail_line
assert cell_failure_detail_line("t", "workflow", 0, {"error_kind": None}) is None
assert cell_failure_detail_line("t", "workflow", 0, {"error_kind": "x"}) is None
line = cell_failure_detail_line(
"trivial-status-json-alias",
"candidate_workflow",
1,
{
"error_kind": "plan-evidence-invalid",
"error_detail": "unauthorized workspace path\ntoken=sk-secret-value",
},
("sk-secret-value",),
)
assert line is not None
assert line.startswith("[trivial-status-json-alias][candidate_workflow][run 1] detail: ")
assert "unauthorized workspace path" in line
assert "sk-secret-value" not in line
assert "\n" not in line
def test_cell_failure_detail_line_bounds_a_huge_detail() -> None:
from workflow_bench.runner import MAX_CELL_DETAIL_CHARS, cell_failure_detail_line
line = cell_failure_detail_line(
"t", "workflow", 0, {"error_kind": "session-error", "error_detail": {"stdout_tail": "y" * 50_000}}
)
assert line is not None
assert "truncated" in line
assert len(line) < MAX_CELL_DETAIL_CHARS + 200
def test_progress_neutralizes_github_actions_annotation_forms() -> None:
rewritten = neutralize_ci_log_text(
"gitnexus/src/cli/optional-grammars.ts(18,36): error TS2307: Cannot find module "
"'gitnexus-shared'\n::error::Composite projects may not disable incremental compilation.\n"
"##[error]tsc failed"
)
assert "): error TS2307" not in rewritten
assert "): compiler-error TS2307" in rewritten
assert "::error::" not in rewritten
assert "[:]error::" in rewritten
assert "##[error]" not in rewritten
assert "# [error]tsc failed" in rewritten
stream = io.StringIO()
progress = SessionProgress("review-pr-2718-defect-ce_review-run0", stream=stream, heartbeat_s=3600)
events = [
{
"type": "assistant",
"message": {
"content": [{"type": "tool_use", "id": "b1", "name": "Bash", "input": {"command": "npx tsc --noEmit"}}]
},
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "b1",
"is_error": True,
"content": "gitnexus/src/cli/optional-grammars.ts(18,36): error TS2307: Cannot find module 'gitnexus-shared'",
}
]
},
},
]
for event in events:
_observe(progress, (json.dumps(event) + "\n").encode())
output = stream.getvalue()
assert "): error TS2307" not in output
assert "): compiler-error TS2307" in output
assert "result=error" in output

View file

@ -1,279 +0,0 @@
"""The real sweep must reach the right finalization decision.
`enforce_measurement_health` is unit-tested and the call site is pinned
structurally, but neither shows the guard running inside a sweep. These drive
the real `_run_sweep` with cell execution scripted and everything downstream of
it left alone: folding, aggregation, the artifact writers, the health guard and
the exit selection.
The below-breaker case is the decisive one. A fixture of many unusable cells
aborts through the pre-existing outage breaker instead - `review-evidence-invalid`
is systemic with a limit of 5 - and would pass whether or not the finalization
guard exists. One fresh unusable cell stays under that threshold, so only the
guard can catch it.
"""
from __future__ import annotations
import json
import threading
from collections.abc import Callable
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from tests.bench_fixtures import scored_review_row, unusable_review_row
from workflow_bench import runner
TASK = {
"id": "review-pr-2718-defect",
"repo": "~/GitNexus",
"ref": "a" * 40,
"prompt": "review it",
"verify": "true",
"class": "review-defect",
}
def _args(out: Path, **overrides: Any) -> SimpleNamespace:
values: dict[str, Any] = dict(
arms=["review"], claude_bin="claude", effort="xhigh", model="gpt-5.6-sol",
out=out, outage_streak=runner.DEFAULT_OUTAGE_STREAK, promotion_max_task_regression=10.0,
promotion_metric="review_weighted_f1", promotion_min_improvement=1.0,
promotion_min_runs=1, proposer_model=None, reuse_results=None, runs=1, workers=1,
timeout=60, base_url=None, auth_token=None, permission_mode=None,
)
values.update(overrides)
return SimpleNamespace(**values)
def _snapshot(prefix: str) -> SimpleNamespace:
return SimpleNamespace(
digest=f"{prefix}-content", manifest_digest=f"{prefix}-manifest",
dependency_content_digest=f"{prefix}-dep", dependency_manifest_digest=f"{prefix}-depman",
command_digest=f"{prefix}-command", materialize=lambda *a, **k: None,
)
def _sweep(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
record: dict[str, Any] | Callable[[int], dict[str, Any]],
*,
runs: int = 1,
cancel_event: threading.Event | None = None,
candidate_arms: list[str] | None = None,
arms: list[str] | None = None,
after_cell: Callable[[int, str], None] | None = None,
):
"""Drive the real _run_sweep; only cell execution and setup are scripted.
``after_cell`` runs once a cell's record exists, which is how a test sets
cancellation deterministically at a known point instead of racing a sleep.
"""
out = tmp_path / "out"
def scripted_cell(_ctx: Any, run_idx: int, arm: str) -> dict[str, Any]:
row = dict(record(run_idx) if callable(record) else record)
row.update({"task": TASK["id"], "arm": arm, "run": run_idx, "class": TASK["class"]})
if after_cell is not None:
after_cell(run_idx, arm)
return row
monkeypatch.setattr(runner, "run_cell", scripted_cell)
monkeypatch.setattr(runner, "ensure_task_graph", lambda **k: k["env"].graph_snapshots.__setitem__(
k["graph_key"], _snapshot("graph")))
monkeypatch.setattr(runner.TaskAssetCache, "prepare", lambda self, *a, **k: _snapshot("asset"))
# Binding resolution clones the repo and verifies the ref; that is expensive
# setup, and the bindings it would return are supplied directly instead.
monkeypatch.setattr(
runner, "resolve_task_bindings",
lambda tasks, expected, **k: list(expected),
)
return runner._run_sweep(
_args(out, runs=runs, arms=arms or ["review"]),
parser=SimpleNamespace(error=lambda m: (_ for _ in ()).throw(SystemExit(2))),
tasks=[TASK],
skipped_expensive=[],
oracle_snapshots=[_snapshot("oracle")],
expected_task_bindings=[{"repo_identity": str(tmp_path / "repo"), "resolved_sha": "a" * 40}],
ce_plugin_config=None,
bwrap_bin=Path("/bin/true"),
sandbox_backend="test-double",
runtime_mounts=(),
candidate_arms=candidate_arms or [],
candidate_overlay=None,
overlay_digest=None,
promotion_target_bases={},
cancel_event=cancel_event,
), out
def test_one_unusable_cell_below_the_breaker_reaches_the_finalization_guard(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""The decisive case: too few failures to trip the breaker, so only the guard can catch it."""
streak = runner.systemic_outage_streak("review-evidence-invalid", 0)
assert streak < runner.DEFAULT_OUTAGE_STREAK, "fixture must stay under the breaker"
unusable = unusable_review_row()
with pytest.raises(SystemExit) as exc:
_sweep(tmp_path, monkeypatch, unusable)
assert exc.value.code == 1
out = capsys.readouterr().out
assert "review: UNUSABLE" in out, "the guard must name the arm and its status"
assert "systemic-outage" not in out, "the breaker must not have tripped"
def test_a_zero_score_stays_a_valid_negative_measurement(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""0.0 is a present measurement, not missing evidence.
A truthiness check on the score would misread it as absent and turn a
quality result into an execution-health failure.
"""
zeroed = scored_review_row(
resolved=False, error_kind="oracle-failed",
review_score={"weighted_f1": 0.0}, review_weighted_f1=0.0,
)
_sweep(tmp_path, monkeypatch, zeroed)
out = capsys.readouterr().out
assert "review: OBSERVED_OK" in out
assert "UNUSABLE" not in out
def test_finalization_persists_results_and_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Evidence must survive the sweep, and say the same thing the exit does."""
scored = scored_review_row(resolved=False, error_kind="oracle-failed", review_weighted_f1=0.2)
_result, out = _sweep(tmp_path, monkeypatch, scored)
rows = [json.loads(line) for line in (out / "results.jsonl").read_text().splitlines()]
assert len(rows) == 1 and rows[0]["review_weighted_f1"] == 0.2
assert (out / "report.md").is_file()
def test_cancellation_without_an_outage_exits_130(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""An interrupted sweep is interrupted, not aborted.
One admissible cell lands first so the measurement-health guard classifies
the arm DEGRADED rather than UNUSABLE - otherwise the guard would supply
exit 1 and this test would pass without ever exercising exit selection.
"""
cancel_event = threading.Event()
with pytest.raises(SystemExit) as exc:
_sweep(
tmp_path, monkeypatch, lambda _run: scored_review_row(),
runs=3, cancel_event=cancel_event,
after_cell=lambda run_idx, _arm: cancel_event.set() if run_idx == 0 else None,
)
stdout = capsys.readouterr().out
report = (tmp_path / "out" / "report.md").read_text()
assert "Sweep cancelled" in report, "an interruption must be reported as one"
assert "systemic-outage" not in stdout, "no breaker trip in this scenario"
assert exc.value.code == 130
def test_an_outage_keeps_exit_1_even_though_the_breaker_cancels(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Precedence: the breaker sets cancel_event, so order decides the exit.
Testing cancellation first would relabel every outage a Ctrl-C. The first
cell is admissible for the same reason as above, and the failures after it
are consecutive and systemic, which is what the breaker actually counts.
"""
def cell(run_idx: int) -> dict[str, Any]:
return scored_review_row() if run_idx == 0 else unusable_review_row()
cancel_event = threading.Event()
with pytest.raises(SystemExit) as exc:
_sweep(tmp_path, monkeypatch, cell,
runs=1 + runner.DEFAULT_OUTAGE_STREAK, cancel_event=cancel_event)
stdout = capsys.readouterr().out
report = (tmp_path / "out" / "report.md").read_text()
assert "systemic-outage" in stdout, "the real breaker must have tripped"
assert cancel_event.is_set(), "the breaker cancels in-flight work"
assert "Sweep aborted" in report
assert exc.value.code == 1, "an outage must not become the 130 of a Ctrl-C"
def test_an_interrupted_sweep_keeps_the_evidence_it_already_paid_for(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Cancellation must not discard rows that already cost money.
The completed-run persistence test cannot show this: it never interrupts, so
it would pass even if the writer only ran on the clean path.
"""
cancel_event = threading.Event()
with pytest.raises(SystemExit):
_sweep(
tmp_path, monkeypatch, lambda _run: scored_review_row(review_weighted_f1=0.42),
runs=3, cancel_event=cancel_event,
after_cell=lambda run_idx, _arm: cancel_event.set() if run_idx == 0 else None,
)
rows = [
json.loads(line)
for line in (tmp_path / "out" / "results.jsonl").read_text().splitlines()
]
assert len(rows) == 1, "the cell that completed before cancellation must survive"
assert rows[0]["review_weighted_f1"] == 0.42, "its measurement must survive intact"
assert (tmp_path / "out" / "report.md").is_file()
def test_an_interrupted_sweep_emits_nothing_that_authorizes_promotion(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The semantic condition, not the absence of a file.
promotion.json is still written for an aborted run - it is the record of why
nothing was promoted. What must hold is that nothing in it authorizes a
promotion from partial evidence.
"""
cancel_event = threading.Event()
with pytest.raises(SystemExit):
_sweep(
tmp_path, monkeypatch, lambda _run: scored_review_row(),
runs=3, cancel_event=cancel_event,
# The candidate arm has to RUN, not merely appear in promotion
# metadata: _run_sweep builds cells only from args.arms, so naming it
# in candidate_arms alone left the candidate with no results at all -
# and then "insufficient_evidence" would hold because nothing ran,
# not because partial evidence is barred from promoting.
arms=["review", "candidate_review"],
candidate_arms=["candidate_review"],
after_cell=(
lambda run_idx, arm: cancel_event.set()
if run_idx == 0 and arm == "candidate_review"
else None
),
)
rows = [
json.loads(line)
for line in (tmp_path / "out" / "results.jsonl").read_text().splitlines()
]
assert any(r["arm"] == "candidate_review" for r in rows), (
"the candidate must have produced evidence, or insufficient_evidence "
"would hold merely because nothing ran"
)
promotion = json.loads((tmp_path / "out" / "promotion.json").read_text())
assert promotion["run_status"] == "aborted"
assert promotion["decisions"], "an aborted run still has to say what it decided"
for decision in promotion["decisions"]:
assert decision["decision"] == "insufficient_evidence"
assert any("partial evidence" in reason for reason in decision["reasons"])

View file

@ -5,15 +5,15 @@ from __future__ import annotations
import os import os
import stat import stat
import subprocess import subprocess
from pathlib import Path, PurePosixPath from pathlib import Path
import pytest import pytest
from workflow_bench.proposer_sandbox import VITE_TEMP_DIR, SandboxError from workflow_bench.proposer_sandbox import VITE_TEMP_DIR, SandboxError
from workflow_bench.oracle_assets import TaskOracleSnapshot from workflow_bench.oracle_assets import TaskOracleSnapshot
from workflow_bench.runner_tasks import resolve_task_bindings from workflow_bench.runner_tasks import resolve_task_bindings
from workflow_bench.task_assets import TaskAssetCache, _is_harness_sandbox_copy, stage_task_assets from workflow_bench.task_assets import TaskAssetCache, stage_task_assets
from workflow_bench import runtime_mounts, task_assets from workflow_bench import task_assets
SHA = "a" * 40 SHA = "a" * 40
@ -443,53 +443,3 @@ def test_non_node_modules_dependency_snapshot_has_no_vite_temp(tmp_path: Path) -
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA) snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
captured = {entry.path.as_posix() for entry in snapshot.dependencies[0].entries} captured = {entry.path.as_posix() for entry in snapshot.dependencies[0].entries}
assert not any(path.endswith(VITE_TEMP_DIR) for path in captured) assert not any(path.endswith(VITE_TEMP_DIR) for path in captured)
def test_review_case_sandbox_copy_is_read_from_the_harness_not_the_task_repo(
monkeypatch, tmp_path: Path
) -> None:
repo = tmp_path / "task-repo"
repo.mkdir()
(repo / "eval" / "workflow_bench").mkdir(parents=True)
harness = tmp_path / "harness"
patch = harness / "eval" / "workflow_bench" / "review_cases" / "pr-2718.patch"
patch.parent.mkdir(parents=True)
patch.write_bytes(b"diff --git a/a b/a\n")
monkeypatch.setattr(runtime_mounts, "HARNESS_ROOT", harness)
task = {
"sandbox_copy": ["eval/workflow_bench/review_cases/pr-2718.patch"],
"sandbox_dependencies": [],
}
with TaskAssetCache(tmp_path / "cache") as cache:
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
copied = snapshot.root / "sandbox-copy" / "eval" / "workflow_bench" / "review_cases" / "pr-2718.patch"
assert copied.read_bytes() == b"diff --git a/a b/a\n"
def test_review_case_sandbox_copy_does_not_fall_back_to_the_task_repo(
monkeypatch, tmp_path: Path
) -> None:
repo = tmp_path / "task-repo"
planted = repo / "eval" / "workflow_bench" / "review_cases" / "pr-2718.patch"
planted.parent.mkdir(parents=True)
planted.write_bytes(b"from-task-repo")
harness = tmp_path / "harness"
harness.mkdir()
monkeypatch.setattr(runtime_mounts, "HARNESS_ROOT", harness)
task = {
"sandbox_copy": ["eval/workflow_bench/review_cases/pr-2718.patch"],
"sandbox_dependencies": [],
}
with TaskAssetCache(tmp_path / "cache") as cache:
with pytest.raises(SandboxError, match="unavailable"):
cache.prepare(task, repo=repo, resolved_sha=SHA)
def test_harness_sandbox_copy_does_not_treat_parent_escapes_as_corpus() -> None:
assert _is_harness_sandbox_copy(PurePosixPath("eval/workflow_bench/review_cases/pr.patch"))
assert not _is_harness_sandbox_copy(
PurePosixPath("eval/workflow_bench/review_cases/../oracles/hidden.json")
)
assert not _is_harness_sandbox_copy(PurePosixPath("eval/workflow_bench/oracles"))

View file

@ -1,39 +1,24 @@
"""Unit tests for workflow benchmark aggregation, reporting, task, and CI contracts.""" """Unit tests for workflow benchmark aggregation, reporting, task, and CI contracts."""
import json import json
import os
import re import re
import shlex
import subprocess import subprocess
import threading
from pathlib import Path from pathlib import Path
import pytest import pytest
import yaml import yaml
from typing import Any
from workflow_bench import runner
from workflow_bench.evolution import CANDIDATE_ARMS
from workflow_bench.process_control import _CANCELLATION, cancellation_scope
from workflow_bench.runner import ( from workflow_bench.runner import (
aggregate, aggregate,
GraphBuildEnv,
arm_health,
broken_incumbent_arms, broken_incumbent_arms,
unhealthy_arms,
unmeasured_arms,
build_parser, build_parser,
infra_error_record, infra_error_record,
next_graph_prefetch_target,
normalized_model_identifier, normalized_model_identifier,
parse_shortstat, parse_shortstat,
prefetch_next_graph,
render_report, render_report,
savings, savings,
select_tasks, select_tasks,
systemic_outage_streak, systemic_outage_streak,
task_has_planned_paid_cells,
) )
@ -76,15 +61,6 @@ def test_aggregate_takes_medians_and_counts_resolved():
"diff_deletions": 5, "diff_deletions": 5,
"class": "demo", "class": "demo",
"resolved": 2, "resolved": 2,
# None of these are reused, so every resolution was measured this sweep.
"resolved_fresh": 2,
# Health is counted separately from resolution: all three executed and
# produced usable evidence, including the one that resolved nothing.
"fresh_attempts": 3,
"admissible": 3,
"execution_failures": 0,
"evidence_failures": 0,
"health_reasons": [],
"runs": 3, "runs": 3,
"valid_runs": 3, "valid_runs": 3,
"excluded_runs": 0, "excluded_runs": 0,
@ -113,7 +89,7 @@ def task_row(task_id: str, **overrides):
"command": "true", "command": "true",
"files": [ "files": [
{ {
"source": "trivial-status-json-alias.oracle.test.ts", "source": "trivial-version-alias.oracle.test.ts",
"target": "oracle.test.ts", "target": "oracle.test.ts",
} }
], ],
@ -185,7 +161,7 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
step for step in containment["steps"] if str(step.get("uses", "")).startswith("actions/setup-node@") step for step in containment["steps"] if str(step.get("uses", "")).startswith("actions/setup-node@")
) )
claude_lock = json.loads((repo_root / ".github" / "claude-canary-runtime" / "package-lock.json").read_text()) claude_lock = json.loads((repo_root / ".github" / "claude-canary-runtime" / "package-lock.json").read_text())
setup_uv = "astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7" setup_uv = "astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990"
assert workflow.count(setup_uv) >= 3 assert workflow.count(setup_uv) >= 3
assert workflow.count("version: '0.11.23'") >= 3 assert workflow.count("version: '0.11.23'") >= 3
assert workflow.count("uv run --locked --extra dev python -m pytest") >= 3 assert workflow.count("uv run --locked --extra dev python -m pytest") >= 3
@ -195,11 +171,6 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
assert containment["env"] == { assert containment["env"] == {
"GITNEXUS_REQUIRE_BWRAP_CANARY": "1", "GITNEXUS_REQUIRE_BWRAP_CANARY": "1",
"GITNEXUS_REQUIRE_CLAUDE_CANARY": "1", "GITNEXUS_REQUIRE_CLAUDE_CANARY": "1",
# This job is the only place with bubblewrap, the pinned runtime and a
# built GitNexus together, so it is where the offline sweep runs with
# nothing provisioning-stubbed. Pinned here so the gate cannot be
# dropped and leave the sweep silently running the stubbed path.
"GITNEXUS_REQUIRE_FULL_SWEEP": "1",
} }
assert containment["timeout-minutes"] == 20 assert containment["timeout-minutes"] == 20
assert containment_node_setup["with"] == { assert containment_node_setup["with"] == {
@ -244,17 +215,7 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
"tests/test_proposer_sandbox.py", "tests/test_proposer_sandbox.py",
"tests/test_workflow_bench_sessions.py", "tests/test_workflow_bench_sessions.py",
"tests/test_ce_plugin_runtime.py", "tests/test_ce_plugin_runtime.py",
# The offline sweep, run here with nothing stubbed: this job is the only
# one carrying bubblewrap, the pinned runtime and a built GitNexus.
"tests/test_offline_sweep_integration.py",
# Carries the real-CLI identity probe, which needs CLAUDE_CANARY_BIN -
# set only on this job. Omitted from this list it skipped everywhere.
"tests/test_mock_provider.py",
# These also need runtime dependencies absent from the locked pytest job.
"tests/test_evolve.py::test_outer_runner_pid_namespace_kills_setsid_descendant",
"tests/test_oracle_assets.py::test_hidden_vitest_config_executes_sibling_oracle_against_candidate_checkout",
"-q", "-q",
"--junitxml=pytest-ubuntu.xml",
] ]
bwrap_canary_marker = re.compile( bwrap_canary_marker = re.compile(
r'@pytest\.mark\.skipif\(\s*os\.environ\.get\("GITNEXUS_REQUIRE_BWRAP_CANARY"\)', r'@pytest\.mark\.skipif\(\s*os\.environ\.get\("GITNEXUS_REQUIRE_BWRAP_CANARY"\)',
@ -270,87 +231,18 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
assert "eval-containment-windows:" in workflow assert "eval-containment-windows:" in workflow
@pytest.fixture(scope="module")
def ci_test_jobs():
repo_root = Path(__file__).resolve().parents[2]
workflow = yaml.safe_load((repo_root / ".github" / "workflows" / "ci-tests.yml").read_text())
return workflow["jobs"]
def test_platform_ci_preserves_required_check_names_with_complete_execution_gates(ci_test_jobs):
jobs = ci_test_jobs
gate = jobs["protected-platform-checks"]
matrix = gate["strategy"]["matrix"]
names = {
"tests / " + gate["name"].replace("${{ matrix.os }}", platform).replace("${{ matrix.check }}", str(check))
for platform in matrix["os"]
for check in matrix["check"]
}
assert names == {
f"tests / {platform} (platform-sensitive) {check}/3"
for platform in ("windows-latest", "macos-latest")
for check in (1, 2, 3)
}
plan = jobs["shard-plan"]["steps"][0]["run"]
total = int(re.search(r"^TOTAL=(\d+)\b", plan, re.MULTILINE)[1])
native_names = {
"tests / "
+ jobs["cross-platform"]["name"]
.replace("${{ matrix.os }}", platform)
.replace("${{ matrix.shard }}", str(shard))
.replace("${{ needs.shard-plan.outputs.total }}", str(total))
for platform in jobs["cross-platform"]["strategy"]["matrix"]["os"]
for shard in range(1, total + 1)
}
assert names.isdisjoint(native_names)
assert set(gate["needs"]) == {"cross-platform", "test-completeness"}
assert gate["if"] == "always()"
assert not gate.get("continue-on-error", False)
assert not jobs["cross-platform"].get("continue-on-error", False)
assert not jobs["test-completeness"].get("continue-on-error", False)
assert gate["permissions"] == {}
assert len(gate["steps"]) == 1
step = gate["steps"][0]
assert not step.get("continue-on-error", False)
assert "if" not in step
assert step["shell"] == "bash"
assert step["env"] == {
"NATIVE_RESULT": "${{ needs.cross-platform.result }}",
"EXECUTION_RESULT": "${{ needs.test-completeness.result }}",
}
@pytest.mark.parametrize("native_result", ["success", "failure", "cancelled", "skipped", ""])
@pytest.mark.parametrize("execution_result", ["success", "failure", "cancelled", "skipped", ""])
def test_required_platform_check_fails_unless_every_dependency_passed(ci_test_jobs, native_result, execution_result):
step = ci_test_jobs["protected-platform-checks"]["steps"][0]
result = subprocess.run(
["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]],
env={**os.environ, "NATIVE_RESULT": native_result, "EXECUTION_RESULT": execution_result},
capture_output=True,
text=True,
timeout=10,
check=False,
)
expected = 0 if native_result == execution_result == "success" else 1
assert result.returncode == expected, result.stdout + result.stderr
def test_shipped_scenarios_opt_out_the_cross_module_cell_and_rebuild_graph_assets(): def test_shipped_scenarios_opt_out_the_cross_module_cell_and_rebuild_graph_assets():
task_file = Path(__file__).resolve().parents[1] / "workflow_bench" / "tasks.scenarios.yaml" task_file = Path(__file__).resolve().parents[1] / "workflow_bench" / "tasks.scenarios.yaml"
tasks = yaml.safe_load(task_file.read_text())["tasks"] tasks = yaml.safe_load(task_file.read_text())["tasks"]
selected, skipped = select_tasks(tasks, include_expensive=False) selected, skipped = select_tasks(tasks, include_expensive=False)
assert [task["id"] for task in selected] == [ assert [task["id"] for task in selected] == [
"trivial-status-json-alias", "trivial-version-alias",
"inv-bug-c-system-include", "inv-bug-pdg-note",
"inv-feature-list-repos-filter", "inv-feature-list-repos-filter",
] ]
assert skipped == ["cross-module-parse-retry"] assert skipped == ["cross-module-parse-retry"]
assert all(not task.get("sandbox_copy") for task in tasks) assert all(not task.get("sandbox_copy") for task in tasks)
assert all(task["sandbox_dependencies"] for task in tasks) assert all(task["sandbox_dependencies"] for task in tasks)
assert all(
any(dep.get("source") == "gitnexus-shared/dist" for dep in task["sandbox_dependencies"]) for task in tasks
)
assert all(task["oracle"]["command"] and task["oracle"]["files"] for task in tasks) assert all(task["oracle"]["command"] and task["oracle"]["files"] for task in tasks)
assert all("./node_modules/.bin/vitest run" in task["oracle"]["command"] for task in tasks) assert all("./node_modules/.bin/vitest run" in task["oracle"]["command"] for task in tasks)
assert all("npx vitest" not in task["oracle"]["command"] for task in tasks) assert all("npx vitest" not in task["oracle"]["command"] for task in tasks)
@ -428,36 +320,6 @@ def test_aggregate_excludes_unverified_transcript_evidence():
assert agg["excluded_runs"] == 1 assert agg["excluded_runs"] == 1
def test_aggregate_excludes_invalid_review_artifacts_from_quality_metrics():
scored = record(
cost_usd=1.0,
review_weighted_f1=0.8,
review_true_positives=2,
review_false_positives=0,
review_false_negatives=1,
review_precision=1.0,
review_recall=0.67,
review_f1=0.8,
review_weighted_precision=0.8,
review_weighted_recall=0.8,
review_blocker_recall=1.0,
review_severity_accuracy=1.0,
review_category_accuracy=1.0,
review_grounded_evidence=1.0,
review_clean_control=False,
)
agg = aggregate(
[
scored,
record(cost_usd=2.0, resolved=False, error_kind="review-evidence-invalid"),
]
)
assert agg["valid_runs"] == 1
assert agg["excluded_runs"] == 1
assert agg["review_weighted_f1"] == 0.8
assert agg["review_true_positives"] == 2
def test_render_report_surfaces_excluded_and_unverified_runs(): def test_render_report_surfaces_excluded_and_unverified_runs():
results = { results = {
"t": { "t": {
@ -565,600 +427,3 @@ def test_outage_streak_flag_defaults_and_disables():
base = ["--tasks", "tasks.yaml", "--model", "claude-sonnet-4-20250514"] base = ["--tasks", "tasks.yaml", "--model", "claude-sonnet-4-20250514"]
assert build_parser().parse_args(base).outage_streak == 5 assert build_parser().parse_args(base).outage_streak == 5
assert build_parser().parse_args([*base, "--outage-streak", "0"]).outage_streak == 0 assert build_parser().parse_args([*base, "--outage-streak", "0"]).outage_streak == 0
def test_run_evolution_script_is_the_shared_ci_and_local_entrypoint():
eval_dir = Path(__file__).resolve().parents[1]
script = eval_dir / "workflow_bench" / "run-evolution.sh"
workflow = eval_dir.parent / ".github" / "workflows" / "gitnexus-skill-evolution.yml"
assert script.is_file()
assert script.stat().st_mode & 0o111
workflow_text = workflow.read_text()
assert "./workflow_bench/run-evolution.sh --apply" in workflow_text
assert "python -m workflow_bench.evolve" not in workflow_text
env = {
"PATH": os.environ.get("PATH", "/usr/bin"),
"MODEL": "claude-sonnet-5",
"PROPOSER_MODEL": "claude-opus-4-8",
"EFFORT": "xhigh",
"GENERATIONS": "1",
"RUNS": "3",
"WORKERS": "2",
"PROVIDER": "openai",
"INCLUDE_EXPENSIVE": "1",
"SEED_RESULTS": "/tmp/seed-bench",
"CLAUDE_BIN": "/opt/claude",
"OUT_ROOT": "/tmp/wfevolve",
"CE_PLUGIN_DIR": "/tmp/ce-plugin",
"CE_PLUGIN_VERSION": "3.24.0",
"HOME": os.environ.get("HOME", "/tmp"),
}
printed = subprocess.run(
[str(script), "--dry-run", "--apply"],
check=True,
capture_output=True,
text=True,
env=env,
)
argv = shlex.split(printed.stdout)
assert argv[:7] == ["uv", "run", "--locked", "--extra", "dev", "python", "-m"]
assert argv[7] == "workflow_bench.evolve"
assert argv[argv.index("--tasks") + 1] == "workflow_bench/tasks.review.scenarios.yaml"
assert argv[argv.index("--arms") + 1] == "review"
assert argv[argv.index("--ce-plugin-version") + 1] == "3.24.0"
assert argv[argv.index("--model") + 1] == "gpt-5.6-sol"
assert argv[argv.index("--proposer-model") + 1] == "gpt-5.6-sol"
assert argv[argv.index("--effort") + 1] == "xhigh"
assert argv[argv.index("--workers") + 1] == "2"
assert argv[argv.index("--claude-bin") + 1] == "/opt/claude"
assert argv[argv.index("--out-root") + 1] == "/tmp/wfevolve"
assert argv[argv.index("--seed-results") + 1] == "/tmp/seed-bench"
assert "--apply" in argv
assert "--include-expensive" in argv
assert "claude-sonnet-5" not in argv
assert printed.stderr # rewrite notice goes to stderr
def test_planned_paid_cells_treat_missing_reuse_as_paid():
task = {"id": "review-pr-2718-defect"}
assert task_has_planned_paid_cells(
task,
arms=["ce_review", "review", "candidate_review"],
runs=3,
reusable_rows={},
reuse_source=None,
)
reuse_source = Path("/tmp/seed")
rows = {
(task["id"], arm, run_idx): {}
for run_idx in range(3)
for arm in ("ce_review", "review", "candidate_review")
}
assert not task_has_planned_paid_cells(
task,
arms=["ce_review", "review", "candidate_review"],
runs=3,
reusable_rows=rows,
reuse_source=reuse_source,
)
del rows[(task["id"], "candidate_review", 0)]
assert task_has_planned_paid_cells(
task,
arms=["ce_review", "review", "candidate_review"],
runs=3,
reusable_rows=rows,
reuse_source=reuse_source,
)
def test_next_graph_prefetch_skips_ready_shas_and_fully_reused_tasks(tmp_path: Path):
first = {"id": "review-a"}
second = {"id": "review-b"}
third = {"id": "review-c"}
reuse_source = tmp_path / "seed"
reused_second = {
(second["id"], arm, 0): {} for arm in ("ce_review", "review", "candidate_review")
}
target = next_graph_prefetch_target(
[
(first, {"repo_identity": "/repo", "resolved_sha": "aaa"}),
(second, {"repo_identity": "/repo", "resolved_sha": "bbb"}),
(third, {"repo_identity": "/repo", "resolved_sha": "ccc"}),
],
arms=["ce_review", "review", "candidate_review"],
runs=1,
reusable_rows=reused_second,
reuse_source=reuse_source,
ready_keys={("/repo", "aaa")},
)
assert target is not None
task, binding, key = target
assert task["id"] == "review-c"
assert key == ("/repo", "ccc")
assert binding["resolved_sha"] == "ccc"
def test_prefetch_next_graph_runs_ensure_on_a_background_thread(monkeypatch):
started = threading.Event()
seen: list[tuple[str, str]] = []
def fake_ensure(**kwargs):
seen.append(kwargs["graph_key"])
started.set()
monkeypatch.setattr("workflow_bench.runner.ensure_task_graph", fake_ensure)
cancel = threading.Event()
job = prefetch_next_graph(
task={"id": "review-b"},
binding={"repo_identity": "/repo", "resolved_sha": "bbb"},
graph_key=("/repo", "bbb"),
env=GraphBuildEnv(
trees=Path("/tmp"),
task_asset_cache=None,
claude_bin="claude",
bwrap_bin="bwrap",
sandbox_backend="bwrap",
runtime_mounts=(),
clone_templates={},
clone_template_errors={},
graph_snapshots={},
graph_snapshot_errors={},
),
cancel_event=cancel,
)
assert job.key == ("/repo", "bbb")
assert started.wait(timeout=2)
job.join()
assert seen == [("/repo", "bbb")]
def test_a_reused_resolution_does_not_count_as_this_sweeps_health():
"""resolved counts evidence; resolved_fresh counts evidence measured today.
broken_incumbent_arms reads resolved_fresh because a reused row proves last
generation's environment worked. Counting it would make an arm whose cells
were all reused look healthy in exactly the run where a broken environment
should have been caught.
"""
reused = [record(resolved=True, reused=True), record(resolved=True, reused=True)]
agg = aggregate(reused)
assert agg["resolved"] == 2
assert agg["resolved_fresh"] == 0
assert broken_incumbent_arms({"t": {"review": agg}}, {"review"}) == ["review"]
mixed = aggregate([record(resolved=True, reused=True), record(resolved=True)])
assert mixed["resolved_fresh"] == 1
assert broken_incumbent_arms({"t": {"review": mixed}}, {"review"}) == []
def test_graph_build_env_ready_keys_covers_successes_and_failures():
"""A key that failed is attempted, not pending.
next_graph_prefetch_target skips keys already in ready_keys. If a failed
build were omitted, the sweep would prefetch it again every iteration and
pay a full clone and offline index each time for a build that cannot
succeed.
"""
env = GraphBuildEnv(
trees=Path("/tmp"),
task_asset_cache=None,
claude_bin="claude",
bwrap_bin="bwrap",
sandbox_backend="bwrap",
runtime_mounts=(),
clone_templates={("/repo", "aaa"): (Path("/tmp/a"), "aaa")},
clone_template_errors={("/repo", "bbb"): OSError("clone failed")},
graph_snapshots={("/repo", "ccc"): object()},
graph_snapshot_errors={("/repo", "ddd"): OSError("index failed")},
)
assert env.ready_keys() == {
("/repo", "aaa"),
("/repo", "bbb"),
("/repo", "ccc"),
("/repo", "ddd"),
}
def _cell(**overrides) -> dict[str, Any]:
"""One results.jsonl row, healthy unless told otherwise."""
base = record(resolved=True)
base.update({"error_kind": None, "review_evidence_valid": True, "transcript_missing": False})
base.update(overrides)
return base
def _arms(**by_arm) -> dict[str, dict[str, dict[str, Any]]]:
return {"task0": {arm: aggregate(rows) for arm, rows in by_arm.items()}}
def test_a_reviewer_that_scores_badly_is_not_an_unhealthy_harness():
"""Reconstructed from Actions run 33962002890's logged observations.
Every completed cell was resolved=False with error_kind=oracle-failed, at a
median score of 0.212 — the reviews ran, wrote artifacts and were scored.
That is a valid negative for the quality gate to judge. Diagnosing it as a
broken environment is the confusion this classification exists to end.
"""
scored_but_wrong = [_cell(resolved=False, error_kind="oracle-failed") for _ in range(3)]
results = _arms(review=scored_but_wrong, ce_review=list(scored_but_wrong))
assert unhealthy_arms(results, {"review", "ce_review"}) == []
health = arm_health(results, {"review"})["review"]
assert health.admissible == 3 and health.fresh_attempts == 3
assert (health.execution_failures, health.evidence_failures) == (0, 0)
def test_an_all_zero_score_is_still_a_valid_negative():
zeroed = [_cell(resolved=False, error_kind="oracle-failed", review_weighted_f1=0.0) for _ in range(3)]
assert unhealthy_arms(_arms(review=zeroed), {"review"}) == []
def test_artifacts_that_were_never_written_are_an_unhealthy_harness():
"""Reconstructed from Actions run 33912693948.
All 41 artifacts came back 0 bytes because the mount made an atomic write
impossible. The reviews could not produce evidence at all — the opposite of
the case above, and the one a health check must catch. The old caller
excluded review arms entirely, so it could not have.
"""
unwritable = [_cell(resolved=False, ok=False, error_kind="review-evidence-invalid") for _ in range(3)]
flagged = unhealthy_arms(_arms(review=unwritable), {"review"})
assert [h.arm for h in flagged] == ["review"]
assert flagged[0].evidence_failures == 3
assert "review-evidence-invalid" in flagged[0].reasons
def test_one_admissible_cell_leaves_an_arm_degraded_not_healthy():
"""Mixed outcomes are DEGRADED. One usable measurement does not erase two failures.
Not fatal - the sweep still produced evidence - but calling it healthy is
how a partly-broken environment passes review.
"""
mixed = [
_cell(resolved=False, error_kind="oracle-failed"),
_cell(resolved=False, ok=False, error_kind="session-error"),
_cell(resolved=False, ok=False, error_kind="infra-error"),
]
results = _arms(review=mixed)
health = arm_health(results, {"review"})["review"]
assert health.status == "DEGRADED"
assert unhealthy_arms(results, {"review"}) == [], "degraded is diagnostic, not fatal"
assert health.execution_failures == 2, "failures must stay visible, not be erased"
assert health.admissible == 1
def test_a_row_that_fails_both_ways_is_only_subtracted_once():
"""run_arm can produce a row that is an execution AND an evidence failure.
It keeps the first error_kind — a session-error survives — and still sets
review_evidence_valid=False when the artifact will not parse. Counting that
row against admissible twice zeroed an arm that held a real measurement,
which arm_health reports as UNUSABLE and the measurement gate then fails on.
"""
both = _cell(resolved=False, ok=False, error_kind="session-error", review_evidence_valid=False)
results = _arms(review=[both, _cell(resolved=True, error_kind="oracle-failed")])
health = arm_health(results, {"review"})["review"]
assert (health.execution_failures, health.evidence_failures) == (1, 1)
assert health.fresh_attempts == 2
assert health.admissible == 1
assert health.status == "DEGRADED"
assert unhealthy_arms(results, {"review"}) == []
def test_reused_rows_alone_leave_current_health_unknown():
"""Historical success cannot certify this sweep's environment."""
reused = [_cell(reused=True) for _ in range(3)]
results = _arms(review=reused)
assert unmeasured_arms(results, {"review"}) == ["review"]
assert unhealthy_arms(results, {"review"}) == []
assert arm_health(results, {"review"})["review"].measured is False
def test_the_paid_canary_survives_a_prior_run_with_more_run_indices():
"""The canary counts planned cells, not every key reuse selection returned.
Reuse selection accepts any non-negative prior `run`, so a results directory
produced with --runs 5 leaves keys this sweep never plans. Comparing against
those made the "arm is fully reused" test false exactly when it was true,
and the incumbent went a whole sweep without one measured cell.
"""
tasks = [{"id": "task0"}, {"id": "task1"}]
reusable = {(task["id"], "review", run): {} for task in tasks for run in range(5)}
dropped = runner.drop_canary_reuse_key(reusable, arm="review", tasks=tasks, runs=3)
assert dropped == ("task0", "review", 0)
assert dropped not in reusable
# A second call is a no-op: the arm now has its paid cell.
assert runner.drop_canary_reuse_key(reusable, arm="review", tasks=tasks, runs=3) is None
def test_an_arm_with_a_planned_paid_cell_keeps_every_reusable_row():
tasks = [{"id": "task0"}]
reusable = {("task0", "review", 0): {}}
assert runner.drop_canary_reuse_key(reusable, arm="review", tasks=tasks, runs=2) is None
assert len(reusable) == 1
def test_reused_successes_do_not_mask_fresh_execution_failures():
rows = [_cell(reused=True), _cell(reused=True), _cell(ok=False, error_kind="session-error")]
flagged = unhealthy_arms(_arms(review=rows), {"review"})
assert [h.arm for h in flagged] == ["review"]
assert flagged[0].fresh_attempts == 1 and flagged[0].execution_failures == 1
def test_a_parseable_artifact_does_not_excuse_a_failed_session():
"""Artifact parseability must not override an execution failure."""
rows = [_cell(ok=False, error_kind="session-error", review_evidence_valid=True) for _ in range(2)]
flagged = unhealthy_arms(_arms(review=rows), {"review"})
assert [h.arm for h in flagged] == ["review"]
assert flagged[0].execution_failures == 2
def test_a_single_unusable_review_is_caught_below_the_breaker_threshold():
"""The decisive regression for the finalization guard.
A fixture of 41 empty artifacts would abort through the outage breaker -
review-evidence-invalid is systemic and the limit is 5 - so it proves
nothing about this path. One fresh unusable cell is under that threshold,
which leaves the finalization check as the only thing that can catch it.
"""
streak = 0
for _ in range(1):
streak = runner.systemic_outage_streak("review-evidence-invalid", streak)
assert streak < runner.DEFAULT_OUTAGE_STREAK, "fixture must not reach the breaker"
results = _arms(review=[_cell(resolved=False, ok=False, error_kind="review-evidence-invalid")])
with pytest.raises(SystemExit) as exc:
runner.enforce_measurement_health(results, {"review"})
assert exc.value.code == 1
def test_finalization_reports_every_arm_and_names_no_cause(capsys):
"""Status for each arm; an empty artifact does not become an EROFS diagnosis."""
results = _arms(
review=[_cell(resolved=False, ok=False, error_kind="review-evidence-invalid")],
ce_review=[_cell(resolved=False, error_kind="oracle-failed")],
)
with pytest.raises(SystemExit):
runner.enforce_measurement_health(results, {"review", "ce_review"})
out = capsys.readouterr().out
assert "review: UNUSABLE" in out
assert "ce_review: OBSERVED_OK" in out
assert "cause=undetermined" in out
assert "EROFS" not in out and "mount" not in out
def test_valid_negatives_do_not_abort_finalization(capsys):
"""The 16h run's shape must survive the real guard, not just the classifier."""
scored_but_wrong = [_cell(resolved=False, error_kind="oracle-failed") for _ in range(3)]
health = runner.enforce_measurement_health(
_arms(review=scored_but_wrong, ce_review=list(scored_but_wrong)), {"review", "ce_review"}
)
assert {h.status for h in health.values()} == {"OBSERVED_OK"}
assert "UNUSABLE" not in capsys.readouterr().out
def test_reused_only_arm_is_reported_unknown_by_finalization(capsys):
runner.enforce_measurement_health(_arms(review=[_cell(reused=True)]), {"review"})
assert "review: UNKNOWN" in capsys.readouterr().out
def test_run_sweep_calls_the_health_guard_and_not_the_legacy_helper():
"""Pins the wiring the caller correction exposed.
Reads the compiled code object's global references rather than the source
text: deleting the call removes the name and fails this test, which is the
mutation check. It does NOT prove the guard runs end to end - _run_sweep
needs bwrap and a sandbox, so no test here drives it.
"""
referenced = runner._run_sweep.__code__.co_names
assert "enforce_measurement_health" in referenced
assert "broken_incumbent_arms" not in referenced
def test_ce_review_is_classified_even_though_it_is_not_a_candidate_arm():
"""ce_review is a comparator, absent from CANDIDATE_ARMS.
Dropping the `- {"review"}` exclusion alone would have left it unchecked.
"""
assert "ce_review" not in set(CANDIDATE_ARMS.values())
health = arm_health(_arms(ce_review=[_cell()]), {"review", "ce_review"})
assert "ce_review" in health
def _packed_cells(tasks: int, runs: int, arms: tuple[str, ...]) -> list[tuple[str, int, str]]:
return [(f"t{t}", r, a) for t in range(tasks) for r in range(runs) for a in arms]
def test_packed_sweep_runs_every_cell_and_folds_in_submission_order():
"""Fold order is the contract the breaker rests on.
Cells finish in whatever order the pool returns them, but the breaker counts
CONSECUTIVE systemic failures, which only means something in a fixed order.
"""
cells = _packed_cells(3, 2, ("review", "candidate_review"))
folded: list[tuple[str, int, str]] = []
streak, tripped = runner.sweep_packed_cells(
cells,
workers=4,
run=lambda task, run_idx, arm: {"error_kind": None, "review_evidence_valid": True},
on_start=lambda *_: None,
on_record=lambda task, run_idx, arm, _rec: folded.append((task, run_idx, arm)),
outage_streak=0,
outage_limit=0,
)
assert folded == cells
assert (streak, tripped) == (0, False)
def test_packed_sweep_trips_the_breaker_on_the_same_cell_waves_would():
"""Packing must not change WHEN a doomed run aborts, only how it is fed."""
cells = _packed_cells(3, 3, ("review",))
fail_from = 2
folded: list[int] = []
def run(task: str, run_idx: int, arm: str) -> dict[str, Any]:
index = cells.index((task, run_idx, arm))
systemic = index >= fail_from
return {
"error_kind": "session-error" if systemic else None,
"review_evidence_valid": not systemic,
}
streak, tripped = runner.sweep_packed_cells(
cells,
workers=2,
run=run,
on_start=lambda *_: None,
on_record=lambda t, r, a, _rec: folded.append(cells.index((t, r, a))),
outage_streak=0,
outage_limit=runner.DEFAULT_OUTAGE_STREAK,
)
assert tripped is True
assert streak == runner.DEFAULT_OUTAGE_STREAK
# Five consecutive systemic failures starting at index 2 -> trips on index 6.
assert folded[-1] == fail_from + runner.DEFAULT_OUTAGE_STREAK - 1
assert folded == sorted(folded), "records must fold in submission order"
def test_packed_sweep_skips_a_task_whose_assets_never_arrive():
"""A task that cannot be prepared is skipped, not run against nothing."""
cells = _packed_cells(3, 2, ("review",))
ran: list[str] = []
runner.sweep_packed_cells(
cells,
workers=3,
run=lambda task, run_idx, arm: ran.append(task)
or {"error_kind": None, "review_evidence_valid": True},
on_start=lambda *_: None,
on_record=lambda *_: None,
outage_streak=0,
outage_limit=0,
await_ready=lambda task: task != "t1",
)
assert set(ran) == {"t0", "t2"}
assert "t1" not in ran
def test_packed_sweep_workers_inherit_the_runs_cancellation_event():
"""A worker that cannot see the event runs on after the sweep is cancelled.
The cells are submitted from a producer THREAD, and a new thread starts with
an empty context - so copying the context at submission copies the wrong one
unless the caller's is captured first. run_managed falls back to
_CANCELLATION when no event is passed, which is how a cell's subprocesses
learn the run was cancelled at all.
"""
seen: list[threading.Event | None] = []
event = threading.Event()
with cancellation_scope(event):
runner.sweep_packed_cells(
_packed_cells(2, 1, ("review",)),
workers=2,
run=lambda *_: seen.append(_CANCELLATION.get()) or {"error_kind": None},
on_start=lambda *_: None,
on_record=lambda *_: None,
outage_streak=0,
outage_limit=0,
)
assert seen and all(observed is event for observed in seen)
def test_packed_sweep_window_must_keep_the_pool_fed():
with pytest.raises(ValueError, match="window must be at least workers"):
runner.sweep_packed_cells(
_packed_cells(1, 1, ("review",)),
workers=4,
run=lambda *_: {"error_kind": None},
on_start=lambda *_: None,
on_record=lambda *_: None,
outage_streak=0,
outage_limit=0,
window=2,
)
def test_a_raising_packed_cell_still_persists_its_settled_siblings():
"""A crash in one cell must not erase the evidence of cells that finished.
run_cell deliberately lets unexpected harness exceptions propagate, and the
wave scheduler answers that by folding every non-failing sibling before it
re-raises. The packed scheduler has to hold the same contract: the later
cells already ran and already cost money, so losing their rows would mean
paying for evidence the sweep then throws away.
"""
folded: list[tuple[int, str]] = []
started = threading.Event()
def run(task_id: str, run_idx: int, arm: str) -> dict[str, Any]:
if run_idx == 0:
# Let the later cell finish first, so there is settled evidence to
# lose at the moment this one raises.
started.wait(timeout=5)
raise RuntimeError("harness bug in cell 0")
started.set()
return {"error_kind": None}
with pytest.raises(RuntimeError, match="harness bug in cell 0"):
runner.sweep_packed_cells(
_packed_cells(1, 2, ("review",)),
workers=2,
run=run,
on_start=lambda *_: None,
on_record=lambda task_id, run_idx, arm, _rec: folded.append((run_idx, arm)),
outage_streak=0,
outage_limit=0,
)
assert (1, "review") in folded, "the sibling that completed was never recorded"
def test_an_uninvoked_skill_still_counts_toward_the_arm_median():
"""Pins a KNOWN GAP, not a desired behaviour.
A cell whose skill never ran still moves the arm's quality median, even
though an arm exists to measure a SKILL. The narrow fix - filtering those
rows out of the quality metrics - is worse than the gap: valid_runs and
excluded_runs keep counting them, so the promotion gate sees N clean runs
while the median came from fewer. Since the dropped rows are systematically
an arm's worst, that biases toward promoting, and it was measured flipping
keep_incumbent to promote.
Closing it honestly needs a scored-run count and a paired-equality check in
the promotion gate. Pinned here so the half-fix cannot be reapplied without
someone reading why it was reverted.
"""
good = record(review_weighted_f1=1.0, cost_usd=2.0)
uninvoked = record(
review_weighted_f1=0.0, cost_usd=4.0, error_kind="skill-not-invoked", skill_invoked=False
)
agg = aggregate([good, uninvoked])
assert agg["review_weighted_f1"] == 0.5, "the uninvoked row is counted - the known gap"
assert agg["cost_usd"] == 3.0
# The invariant that makes the half-fix unsafe: the median and the run count
# the gate reads must cover the same rows.
assert agg["valid_runs"] == 2

View file

@ -8,18 +8,14 @@ from types import SimpleNamespace
import pytest import pytest
from workflow_bench.evolution import ( from workflow_bench.evolution import (
CANDIDATE_SKILLS,
MAX_CANDIDATE_ENTRIES, MAX_CANDIDATE_ENTRIES,
apply_candidate_overlay, apply_candidate_overlay,
candidate_overlay_digest, candidate_overlay_digest,
evaluate_candidate, evaluate_candidate,
evaluate_review_candidate,
required_candidate_arms, required_candidate_arms,
seed_evaluated_skills,
skill_fingerprint, skill_fingerprint,
unexercised_overlay_skills, unexercised_overlay_skills,
) )
from workflow_bench.promotion_apply import mirror_targets
from workflow_bench.process_control import ManagedProcessResult from workflow_bench.process_control import ManagedProcessResult
from workflow_bench.runner import aggregate, build_parser from workflow_bench.runner import aggregate, build_parser
@ -189,7 +185,8 @@ def test_candidate_overlay_is_skill_only_and_content_addressed(tmp_path):
review_skill = review_overlay / ".claude" / "skills" / "gitnexus-review" / "SKILL.md" review_skill = review_overlay / ".claude" / "skills" / "gitnexus-review" / "SKILL.md"
review_skill.parent.mkdir(parents=True) review_skill.parent.mkdir(parents=True)
review_skill.write_text("review candidate\n") review_skill.write_text("review candidate\n")
assert candidate_overlay_digest(review_overlay) with pytest.raises(ValueError, match="plan,work"):
candidate_overlay_digest(review_overlay)
invalid = tmp_path / "invalid" invalid = tmp_path / "invalid"
source = invalid / "gitnexus" / "src" / "cli" / "index.ts" source = invalid / "gitnexus" / "src" / "cli" / "index.ts"
@ -241,116 +238,6 @@ def test_required_candidate_arms_are_minimal_for_touched_skills(tmp_path):
"candidate_workflow_direct", "candidate_workflow_direct",
] ]
review = tmp_path / "review"
write_overlay_skill(review, "gitnexus-review")
assert required_candidate_arms(review) == ["candidate_review"]
def test_review_gate_is_quality_first_and_requires_repeated_evidence():
def arm(score, blocker=1.0, false_positives=0, runs=3):
return {
"runs": runs,
"valid_runs": runs,
"excluded_runs": 0,
"class": "review-defect",
"review_weighted_f1": score,
"review_blocker_recall": blocker,
"review_false_positives": false_positives,
"review_clean_control": False,
"review_verdict_correct": True,
}
decision = evaluate_review_candidate(
{
"case-a": {
"review": arm(0.6),
"candidate_review": arm(0.8),
}
},
incumbent_arm="review",
candidate_arm="candidate_review",
model="pinned-model",
)
assert decision["decision"] == "promote"
regression = evaluate_review_candidate(
{
"case-a": {
"review": arm(0.6, blocker=1.0),
"candidate_review": arm(0.8, blocker=0.0),
}
},
incumbent_arm="review",
candidate_arm="candidate_review",
model="pinned-model",
)
assert regression["decision"] == "keep_incumbent"
assert any("blocker recall" in reason for reason in regression["reasons"])
def test_review_gate_rejects_added_false_positives_on_clean_controls():
base = {
"runs": 3,
"valid_runs": 3,
"excluded_runs": 0,
"class": "review-clean",
"review_weighted_f1": 1.0,
"review_blocker_recall": 1.0,
"review_clean_control": True,
"review_clean_pass": True,
"review_verdict_correct": True,
}
decision = evaluate_review_candidate(
{
"clean": {
"review": {**base, "review_false_positives": 0},
"candidate_review": {**base, "review_false_positives": 1, "review_clean_pass": False},
}
},
incumbent_arm="review",
candidate_arm="candidate_review",
model="pinned-model",
)
assert decision["decision"] == "keep_incumbent"
assert any("clean control" in reason for reason in decision["reasons"])
@pytest.mark.parametrize("wrong_verdict,bad_blocker", [(True, False), (False, True), (False, False)])
def test_review_gate_preserves_every_repeat_safeguard(wrong_verdict, bad_blocker):
from workflow_bench.runner import aggregate
def row(score, verdict=True, blocker=1.0):
return {
"resolved": True,
"review_weighted_f1": score,
"review_blocker_recall": blocker,
"review_false_positives": 0,
"review_verdict_correct": verdict,
"review_clean_control": False,
"review_clean_pass": False,
}
incumbent = aggregate([row(0.5) for _ in range(3)])
candidate = aggregate([row(0.8), row(0.8), row(0.8, not wrong_verdict, 0.0 if bad_blocker else 1.0)])
decision = evaluate_review_candidate(
{"case": {"review": incumbent, "candidate_review": candidate}},
incumbent_arm="review",
candidate_arm="candidate_review",
model="pinned-model",
)
assert decision["decision"] == ("keep_incumbent" if wrong_verdict or bad_blocker else "promote")
def test_review_gate_treats_an_empty_corpus_as_insufficient_evidence():
decision = evaluate_review_candidate(
{},
incumbent_arm="review",
candidate_arm="candidate_review",
model="pinned-model",
)
assert decision["decision"] == "insufficient_evidence"
assert any("no paired review task results" in reason for reason in decision["reasons"])
@pytest.mark.skipif(os.name == "nt", reason="candidate overlays require the Linux outer sandbox") @pytest.mark.skipif(os.name == "nt", reason="candidate overlays require the Linux outer sandbox")
def test_apply_candidate_overlay_creates_a_clean_ephemeral_commit(tmp_path): def test_apply_candidate_overlay_creates_a_clean_ephemeral_commit(tmp_path):
@ -427,12 +314,6 @@ def test_apply_candidate_overlay_creates_a_clean_ephemeral_commit(tmp_path):
) == candidate_overlay_digest(overlay) ) == candidate_overlay_digest(overlay)
assert incumbent.read_text() == "candidate\n" assert incumbent.read_text() == "candidate\n"
git_commands = [command for command in sandbox.commands if command[0] == "/usr/bin/git"] git_commands = [command for command in sandbox.commands if command[0] == "/usr/bin/git"]
assert git_commands[0][-4:] == [
"add",
"-f",
"--",
".claude/skills/gitnexus-work/SKILL.md",
]
assert [command[-1] for command in git_commands[:2]] == [ assert [command[-1] for command in git_commands[:2]] == [
".claude/skills/gitnexus-work/SKILL.md", ".claude/skills/gitnexus-work/SKILL.md",
"--", "--",
@ -468,181 +349,6 @@ def test_candidate_overlay_rejects_linked_destination_parents(tmp_path):
apply_candidate_overlay(overlay, repo, sandbox=sandbox) apply_candidate_overlay(overlay, repo, sandbox=sandbox)
@pytest.mark.skipif(os.name == "nt", reason="candidate overlays require the Linux outer sandbox")
def test_apply_candidate_overlay_force_adds_historically_ignored_skill(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "--quiet", str(repo)], check=True)
(repo / ".gitignore").write_text(".claude/skills/*\n")
(repo / "README").write_text("subject\n")
subprocess.run(["git", "-C", str(repo), "add", "."], check=True)
subprocess.run(
[
"git",
"-C",
str(repo),
"-c",
"user.name=test",
"-c",
"user.email=test@invalid",
"commit",
"--quiet",
"-m",
"historical checkout that ignores skills",
],
check=True,
)
overlay = tmp_path / "candidate"
write_overlay_skill(overlay, "gitnexus-review")
class LocalSandbox:
def __init__(self):
self.clone = repo
def run(self, command, **kwargs):
if command[0] == "/bin/mkdir":
return ManagedProcessResult(
state="exited",
returncode=0,
stdout_tail="",
stderr_tail="",
duration_s=0.0,
)
translated = [str(repo) if item == "/workspace" else item for item in command]
completed = subprocess.run(
translated,
cwd=repo,
env=dict(kwargs["env"]),
capture_output=True,
text=True,
check=False,
)
return ManagedProcessResult(
state="exited",
returncode=completed.returncode,
stdout_tail=completed.stdout,
stderr_tail=completed.stderr,
duration_s=0.0,
)
apply_candidate_overlay(overlay, repo, sandbox=LocalSandbox())
assert (repo / ".claude" / "skills" / "gitnexus-review" / "SKILL.md").read_text() == (
"gitnexus-review candidate\n"
)
status = subprocess.run(
["git", "-C", str(repo), "status", "--porcelain"],
check=True,
capture_output=True,
text=True,
)
assert status.stdout == ""
@pytest.mark.skipif(os.name == "nt", reason="skill seeds require the Linux outer sandbox")
def test_seed_evaluated_skills_installs_missing_review_skill_and_is_idempotent(tmp_path):
repo = tmp_path / "clone"
repo.mkdir()
subprocess.run(["git", "init", "--quiet", str(repo)], check=True)
# Historical review SHAs ignore the whole skill tree and lack today's
# `!.claude/skills/gitnexus-review/` allowlist. Seeding must still commit.
(repo / ".gitignore").write_text(".claude/skills/*\n")
(repo / "README").write_text("subject\n")
subprocess.run(["git", "-C", str(repo), "add", "."], check=True)
subprocess.run(
[
"git",
"-C",
str(repo),
"-c",
"user.name=test",
"-c",
"user.email=test@invalid",
"commit",
"--quiet",
"-m",
"historical checkout without review skill",
],
check=True,
)
before = subprocess.run(
["git", "-C", str(repo), "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
source = tmp_path / "harness"
skill = source / ".claude" / "skills" / "gitnexus-review" / "SKILL.md"
persona = source / ".claude" / "skills" / "gitnexus-review" / "ci-personas" / "lens.md"
persona.parent.mkdir(parents=True)
skill.write_text("current incumbent review skill\n")
persona.write_text("persona\n")
class LocalSandbox:
def __init__(self):
self.clone = repo
def run(self, command, **kwargs):
if command[0] == "/bin/mkdir":
return ManagedProcessResult(
state="exited",
returncode=0,
stdout_tail="",
stderr_tail="",
duration_s=0.0,
)
translated = [str(repo) if item == "/workspace" else item for item in command]
completed = subprocess.run(
translated,
cwd=repo,
env=dict(kwargs["env"]),
capture_output=True,
text=True,
check=False,
)
return ManagedProcessResult(
state="exited",
returncode=completed.returncode,
stdout_tail=completed.stdout,
stderr_tail=completed.stderr,
duration_s=0.0,
)
sandbox = LocalSandbox()
seed_evaluated_skills(source, repo, sandbox=sandbox, arm="review")
assert skill_fingerprint(repo, "review") is not None
assert (repo / ".claude" / "skills" / "gitnexus-review" / "SKILL.md").read_text() == (
"current incumbent review skill\n"
)
assert (
repo / ".claude" / "skills" / "gitnexus-review" / "ci-personas" / "lens.md"
).read_text() == "persona\n"
status = subprocess.run(
["git", "-C", str(repo), "status", "--porcelain"],
check=True,
capture_output=True,
text=True,
)
assert status.stdout == ""
after = subprocess.run(
["git", "-C", str(repo), "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
assert after != before
seed_evaluated_skills(source, repo, sandbox=sandbox, arm="review")
again = subprocess.run(
["git", "-C", str(repo), "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
assert again == after
@pytest.mark.skipif(os.name == "nt", reason="skill links are rejected by the Linux sandbox harness") @pytest.mark.skipif(os.name == "nt", reason="skill links are rejected by the Linux sandbox harness")
def test_skill_fingerprint_rejects_linked_skill_roots(tmp_path): def test_skill_fingerprint_rejects_linked_skill_roots(tmp_path):
outside = tmp_path / "outside" outside = tmp_path / "outside"
@ -718,7 +424,9 @@ def test_candidate_gate_refuses_promotion_on_unmeasured_cost():
results = { results = {
"task-a": { "task-a": {
"workflow_direct": aggregate([record(cost_usd=1.0) for _ in range(3)]), "workflow_direct": aggregate([record(cost_usd=1.0) for _ in range(3)]),
"candidate_workflow_direct": aggregate([record(cost_usd=0.1), record(cost_usd=None), record(cost_usd=0.1)]), "candidate_workflow_direct": aggregate(
[record(cost_usd=0.1), record(cost_usd=None), record(cost_usd=0.1)]
),
} }
} }
decision = evaluate_candidate( decision = evaluate_candidate(
@ -802,23 +510,8 @@ def test_candidate_gate_rejects_a_partial_candidate_even_with_a_resolution_edge(
assert any("oracle-backed quality floor" in reason for reason in decision["reasons"]) assert any("oracle-backed quality floor" in reason for reason in decision["reasons"])
@pytest.mark.parametrize( @pytest.mark.parametrize("resolved", [0, 2])
("resolved", "expected_decision", "expected_reason"), def test_candidate_gate_never_promotes_zero_or_partial_success_for_efficiency(resolved):
[
# Nothing resolved anywhere: the task is ungated, which leaves the
# generation with no quality signal at all — refuse outright rather
# than rank a 100x cost win across runs that all failed the oracle.
(0, "insufficient_evidence", "no task supplied quality signal"),
# Partial success on a task the incumbent also partly resolves stays a
# quality-floor rejection: the candidate has to be reliable, not lucky.
(2, "keep_incumbent", "oracle-backed quality floor"),
],
)
def test_candidate_gate_never_promotes_zero_or_partial_success_for_efficiency(
resolved,
expected_decision,
expected_reason,
):
incumbent_records = [record(cost_usd=1.0, resolved=index < resolved) for index in range(3)] incumbent_records = [record(cost_usd=1.0, resolved=index < resolved) for index in range(3)]
candidate_records = [record(cost_usd=0.01, resolved=index < resolved) for index in range(3)] candidate_records = [record(cost_usd=0.01, resolved=index < resolved) for index in range(3)]
decision = evaluate_candidate( decision = evaluate_candidate(
@ -833,184 +526,9 @@ def test_candidate_gate_never_promotes_zero_or_partial_success_for_efficiency(
model="pinned-model", model="pinned-model",
) )
assert decision["decision"] == expected_decision assert decision["decision"] == "keep_incumbent"
assert decision["tasks"][0]["candidate_quality_floor_met"] is False assert decision["tasks"][0]["candidate_quality_floor_met"] is False
assert any(expected_reason in reason for reason in decision["reasons"]) assert any("oracle-backed quality floor" in reason for reason in decision["reasons"])
def test_a_task_no_arm_can_resolve_is_reported_but_does_not_veto_promotion():
# inv-feature-list-repos-filter fails its hidden oracle on every run of
# both arms. Gating on it made promotion unreachable for as long as it
# stayed in the set, while saying nothing about the candidate.
solvable = {
"workflow": aggregate([record(cost_usd=1.0, resolved=index > 1) for index in range(3)]),
"candidate_workflow": aggregate([record(cost_usd=1.0) for _ in range(3)]),
}
unsolvable = {
"workflow": aggregate([record(cost_usd=1.0, resolved=False) for _ in range(3)]),
"candidate_workflow": aggregate([record(cost_usd=1.3, resolved=False) for _ in range(3)]),
}
decision = evaluate_candidate(
{"task-a": solvable, "task-impossible": unsolvable},
incumbent_arm="workflow",
candidate_arm="candidate_workflow",
model="pinned-model",
)
assert decision["decision"] == "promote"
assert decision["ungated_tasks"] == ["task-impossible"]
assert decision["gated_tasks"] == ["task-a"]
assert [row["gated"] for row in decision["tasks"]] == [True, False]
# The ungated task's 30% cost regression stays under the failed-task cap
# but must not reach the median or the (tighter) gated per-task cap.
assert decision["median_improvement_pct"] == 0.0
assert not any("above the" in reason for reason in decision["reasons"])
# One aggregate line, so a growing set of unsolvable tasks cannot crowd the
# real verdict out of the three reasons the proposer is shown — and it
# discloses how much of the set the verdict actually rests on.
assert [reason for reason in decision["reasons"] if "not gated on" in reason] == [
"not gated on 1 task(s) neither arm resolved: task-impossible (evidence base: 1/2 paired tasks gated)"
]
def test_an_ungated_task_still_ranks_against_the_failed_task_cost_cap():
# Leaving the quality gate is not leaving the spend gate: burning 9x the
# incumbent's cost to fail the same oracle is a regression the gate has to
# see, or a candidate can hide unbounded waste inside "task health".
solvable = {
"workflow": aggregate([record(cost_usd=1.0, resolved=index > 1) for index in range(3)]),
"candidate_workflow": aggregate([record(cost_usd=1.0) for _ in range(3)]),
}
unsolvable = {
"workflow": aggregate([record(cost_usd=1.0, resolved=False) for _ in range(3)]),
"candidate_workflow": aggregate([record(cost_usd=9.0, resolved=False) for _ in range(3)]),
}
decision = evaluate_candidate(
{"task-a": solvable, "task-impossible": unsolvable},
incumbent_arm="workflow",
candidate_arm="candidate_workflow",
model="pinned-model",
)
assert decision["decision"] == "keep_incumbent"
assert decision["ungated_tasks"] == ["task-impossible"]
assert any("failed-task cap" in reason for reason in decision["reasons"])
def test_a_mutually_failed_task_stays_gated_when_the_skill_never_loaded():
# skill-not-invoked is prompt evidence, not task health: the skill under
# test never ran, so the task cannot be written off as beyond both arms.
results = {
"task-a": {
"workflow": aggregate([record(cost_usd=1.0, resolved=False) for _ in range(3)]),
"candidate_workflow": aggregate(
[record(cost_usd=0.01, resolved=False, error_kind="skill-not-invoked") for _ in range(3)]
),
}
}
decision = evaluate_candidate(
results,
incumbent_arm="workflow",
candidate_arm="candidate_workflow",
model="pinned-model",
)
assert decision["ungated_tasks"] == []
assert decision["tasks"][0]["gated"] is True
assert decision["tasks"][0]["skill_attributable_failure"] is True
# Gated with teeth: the 99% cost "win" must not carry a candidate whose
# skill never loaded.
assert decision["decision"] == "keep_incumbent"
assert any("never invoked the skill under test" in reason for reason in decision["reasons"])
def test_a_mutually_failed_task_stays_gated_when_its_metric_was_never_measured():
# Ungating is a claim about spend as well as quality. With no measured
# cost there is nothing to claim, so the task stays in the gate and the
# missing measurement is named instead of silently skipped.
results = {
"task-a": {
"workflow": aggregate([record(cost_usd=1.0, resolved=False) for _ in range(3)]),
"candidate_workflow": aggregate(
[record(cost_usd=None, resolved=False), *(record(cost_usd=0.1, resolved=False) for _ in range(2))]
),
}
}
decision = evaluate_candidate(
results,
incumbent_arm="workflow",
candidate_arm="candidate_workflow",
model="pinned-model",
)
assert decision["ungated_tasks"] == []
assert decision["decision"] == "insufficient_evidence"
assert any("was not measured on every run" in reason for reason in decision["reasons"])
def test_partial_progress_on_a_task_the_incumbent_never_resolves_is_not_punished():
# Resolving 1 of 3 runs where the incumbent resolves none is strictly
# better than resolving none — which the gate ungates and forgives. Holding
# the partial run to the quality floor made improvement score worse than
# inaction.
def outcome(candidate_resolved: int) -> dict[str, object]:
return evaluate_candidate(
{
"task-a": {
"workflow": aggregate([record(cost_usd=1.0) for _ in range(3)]),
"candidate_workflow": aggregate([record(cost_usd=0.5) for _ in range(3)]),
},
"task-hard": {
"workflow": aggregate([record(cost_usd=1.0, resolved=False) for _ in range(3)]),
"candidate_workflow": aggregate(
[record(cost_usd=1.0, resolved=index < candidate_resolved) for index in range(3)]
),
},
},
incumbent_arm="workflow",
candidate_arm="candidate_workflow",
model="pinned-model",
)
no_progress = outcome(0)
some_progress = outcome(1)
assert no_progress["decision"] == "promote"
assert no_progress["ungated_tasks"] == ["task-hard"]
# The partial run gives the task quality signal, so it is gated — but as
# improvement, not as a floor failure the zero-progress candidate escapes.
assert some_progress["decision"] == "promote"
assert some_progress["ungated_tasks"] == []
assert some_progress["tasks"][1]["quality_floor_enforced"] is False
assert not any("quality floor" in reason for reason in some_progress["reasons"])
def test_promotion_requires_a_gated_majority_of_the_paired_tasks():
# Two of three tasks written off as task health leaves one task deciding
# the whole promotion. Ungating keeps promotion reachable; it must not
# hollow out the evidence base that makes a promotion mean anything.
solvable = {
"workflow": aggregate([record(cost_usd=1.0, resolved=index > 1) for index in range(3)]),
"candidate_workflow": aggregate([record(cost_usd=0.1) for _ in range(3)]),
}
unsolvable = {
"workflow": aggregate([record(cost_usd=1.0, resolved=False) for _ in range(3)]),
"candidate_workflow": aggregate([record(cost_usd=1.0, resolved=False) for _ in range(3)]),
}
decision = evaluate_candidate(
{"task-a": solvable, "task-impossible": unsolvable, "task-impossible-2": dict(unsolvable)},
incumbent_arm="workflow",
candidate_arm="candidate_workflow",
model="pinned-model",
)
assert decision["decision"] == "insufficient_evidence"
assert decision["gated_tasks"] == ["task-a"]
assert any("evidence base is too thin" in reason for reason in decision["reasons"])
def test_candidate_gate_promotes_on_a_two_run_resolution_margin(): def test_candidate_gate_promotes_on_a_two_run_resolution_margin():
@ -1035,34 +553,3 @@ def test_overlay_skills_must_be_exercised_by_selected_candidate_arms(tmp_path):
write_overlay_skill(plan_overlay, "gitnexus-plan") write_overlay_skill(plan_overlay, "gitnexus-plan")
assert unexercised_overlay_skills(plan_overlay, ["candidate_workflow_direct"]) == ["gitnexus-plan"] assert unexercised_overlay_skills(plan_overlay, ["candidate_workflow_direct"]) == ["gitnexus-plan"]
assert unexercised_overlay_skills(plan_overlay, ["candidate_workflow"]) == [] assert unexercised_overlay_skills(plan_overlay, ["candidate_workflow"]) == []
@pytest.mark.parametrize("skill", sorted(CANDIDATE_SKILLS))
def test_a_promoted_skill_is_visible_to_git_status_in_every_shipped_tree(skill):
"""A promotion the repository cannot see is a promotion that never happens.
The workflow detects an applied promotion with `git status --porcelain`,
which is blind to ignored paths, and `.claude/skills/*` is ignored with a
hand-maintained per-skill allowlist. A candidate skill missing from that
allowlist would leave the run reporting "No promotion this run" after the
gate had already said promote — silently, and only after a full generation
of benchmark spend.
"""
repo_root = Path(__file__).resolve().parents[2]
from pathlib import PurePosixPath
targets = [
str(path.parent)
for path in mirror_targets(PurePosixPath(".claude/skills") / skill / "SKILL.md")
]
ignored = [
target
for target in targets
if subprocess.run(
["git", "check-ignore", "-q", f"{target}/SKILL.md"],
cwd=repo_root,
check=False,
).returncode
== 0
]
assert ignored == []

View file

@ -4,7 +4,6 @@ import argparse
import hashlib import hashlib
import json import json
import os import os
import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@ -12,10 +11,9 @@ from types import SimpleNamespace
import pytest import pytest
from workflow_bench import evolve, runner, runner_artifacts, runner_sessions, runtime_mounts from workflow_bench import evolve, runner, runner_sessions, runtime_mounts
from workflow_bench.evolution import skill_fingerprint from workflow_bench.evolution import skill_fingerprint
from workflow_bench.process_control import ManagedProcessError, ManagedProcessResult from workflow_bench.process_control import ManagedProcessResult
from workflow_bench.proposer_sandbox import SandboxError
from workflow_bench.runner import snapshot_plan_docs from workflow_bench.runner import snapshot_plan_docs
@ -73,7 +71,6 @@ def bench_args(**overrides):
"claude_bin": "claude", "claude_bin": "claude",
"timeout": 5, "timeout": 5,
"model": None, "model": None,
"effort": "xhigh",
"base_url": None, "base_url": None,
"auth_token": None, "auth_token": None,
"permission_mode": None, "permission_mode": None,
@ -120,16 +117,10 @@ def skill_events(skill_input: dict, *, tool_id: str = "skill-1", is_error: bool
def fake_sandbox(root: Path) -> SimpleNamespace: def fake_sandbox(root: Path) -> SimpleNamespace:
# private_root is NOT the clone. Conflating them puts the review artifact
# directory inside the workspace, which the real sandbox never does and
# which hides whether the workspace was left untouched.
private_root = root.parent / f"{root.name}-sandbox-private"
private_root.mkdir(exist_ok=True)
return SimpleNamespace( return SimpleNamespace(
backend="test-double",
claude_bin="claude", claude_bin="claude",
clone=root, clone=root,
private_root=private_root, private_root=root,
command_prefix=[], command_prefix=[],
command_prefix_for=lambda **_kwargs: [], command_prefix_for=lambda **_kwargs: [],
settings_json="{}", settings_json="{}",
@ -176,25 +167,6 @@ def test_run_claude_forwards_the_named_model_to_every_session(monkeypatch, tmp_p
assert captured[captured.index("--model") + 1] == "claude-sonnet-4-20250514" assert captured[captured.index("--model") + 1] == "claude-sonnet-4-20250514"
def test_run_claude_forwards_xhigh_effort_to_every_session(monkeypatch, tmp_path):
captured: list[str] = []
def fake_run(command, **kwargs):
captured.extend(command)
return fake_cli_result(VALID_REPORT)
monkeypatch.setattr(runner_sessions, "run_managed", fake_run)
runner.run_claude(
"task",
tmp_path,
claude_bin="claude",
timeout=5,
model="gpt-5.6-sol",
effort="xhigh",
)
assert captured[captured.index("--effort") + 1] == "xhigh"
def test_run_claude_restricts_tools_via_tools_flag_outside_bare(monkeypatch, tmp_path): def test_run_claude_restricts_tools_via_tools_flag_outside_bare(monkeypatch, tmp_path):
# Outside --bare, the built-in toolset defaults to everything (subagents, # Outside --bare, the built-in toolset defaults to everything (subagents,
# WebFetch, Task, ...) and --allowedTools only pre-approves within that — # WebFetch, Task, ...) and --allowedTools only pre-approves within that —
@ -323,18 +295,10 @@ def test_run_arm_keeps_session_error_kind_over_verify(monkeypatch, tmp_path):
def test_agent_tool_grants_are_exact_and_nomcp_has_no_graph_tools(monkeypatch, tmp_path): def test_agent_tool_grants_are_exact_and_nomcp_has_no_graph_tools(monkeypatch, tmp_path):
read_only = runner.allowed_agent_tools(implementation=False) read_only = runner.allowed_agent_tools(implementation=False)
review_tools = runner.allowed_agent_tools(implementation=False, allow_edit=False)
implementation = runner.allowed_agent_tools(implementation=True) implementation = runner.allowed_agent_tools(implementation=True)
no_mcp = runner.allowed_agent_tools(implementation=True, include_mcp=False) no_mcp = runner.allowed_agent_tools(implementation=True, include_mcp=False)
assert read_only == [*runner.BUILTIN_AGENT_TOOLS, *runner.GITNEXUS_READ_ONLY_TOOLS] assert read_only == [*runner.BUILTIN_AGENT_TOOLS, *runner.GITNEXUS_READ_ONLY_TOOLS]
assert review_tools == [
tool
for tool in [*runner.BUILTIN_AGENT_TOOLS, *runner.GITNEXUS_READ_ONLY_TOOLS]
if tool != "Edit"
]
assert "Write" in review_tools
assert "Edit" not in review_tools
assert implementation == [ assert implementation == [
*runner.BUILTIN_AGENT_TOOLS, *runner.BUILTIN_AGENT_TOOLS,
*runner.GITNEXUS_READ_ONLY_TOOLS, *runner.GITNEXUS_READ_ONLY_TOOLS,
@ -362,7 +326,7 @@ def test_agent_tool_grants_are_exact_and_nomcp_has_no_graph_tools(monkeypatch, t
) )
assert captured[0]["allowed_tools"] == read_only # planning assert captured[0]["allowed_tools"] == read_only # planning
assert captured[1]["allowed_tools"] == review_tools # review assert captured[1]["allowed_tools"] == read_only # review
assert captured[2]["allowed_tools"] == implementation assert captured[2]["allowed_tools"] == implementation
assert captured[3]["allowed_tools"] == list(runner.BUILTIN_AGENT_TOOLS) assert captured[3]["allowed_tools"] == list(runner.BUILTIN_AGENT_TOOLS)
assert captured[3]["mcp_config_json"] == '{"mcpServers":{}}' assert captured[3]["mcp_config_json"] == '{"mcpServers":{}}'
@ -391,7 +355,7 @@ def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tm
directory.mkdir(parents=True) directory.mkdir(parents=True)
(runtime / "dist" / "cli" / "index.js").write_text("") (runtime / "dist" / "cli" / "index.js").write_text("")
(runtime / "hooks" / "claude" / "resolve-analyze-cmd.cjs").write_text("") (runtime / "hooks" / "claude" / "resolve-analyze-cmd.cjs").write_text("")
(runtime / "package.json").write_text(json.dumps({"version": "9.9.9-test"})) (runtime / "package.json").write_text(json.dumps({"version": runner.PINNED_GITNEXUS_VERSION}))
(runtime / "node_modules" / "gitnexus-shared").symlink_to(shared, target_is_directory=True) (runtime / "node_modules" / "gitnexus-shared").symlink_to(shared, target_is_directory=True)
(shared / "package.json").write_text(json.dumps({"name": "gitnexus-shared"})) (shared / "package.json").write_text(json.dumps({"name": "gitnexus-shared"}))
monkeypatch.setattr(runtime_mounts, "HARNESS_ROOT", tmp_path) monkeypatch.setattr(runtime_mounts, "HARNESS_ROOT", tmp_path)
@ -415,6 +379,9 @@ def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tm
(shared / "package.json", f"{runner.SANDBOX_GITNEXUS_SHARED}/package.json"), (shared / "package.json", f"{runner.SANDBOX_GITNEXUS_SHARED}/package.json"),
(runtime / "hooks" / "claude", f"{runner.SANDBOX_GITNEXUS}/hooks/claude"), (runtime / "hooks" / "claude", f"{runner.SANDBOX_GITNEXUS}/hooks/claude"),
] ]
package = json.loads((runtime / "package.json").read_text())
assert package["version"] == runner.PINNED_GITNEXUS_VERSION
mounted_sources = {mount.source for mount in mounts} mounted_sources = {mount.source for mount in mounts}
mounted_targets = {mount.target for mount in mounts} mounted_targets = {mount.target for mount in mounts}
assert runtime not in mounted_sources assert runtime not in mounted_sources
@ -432,118 +399,6 @@ def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tm
assert f"{runner.SANDBOX_GITNEXUS}/hooks" not in mounted_targets assert f"{runner.SANDBOX_GITNEXUS}/hooks" not in mounted_targets
def _install_pinned_runtime(root: Path) -> None:
runtime = root / "gitnexus"
shared = root / "gitnexus-shared"
for directory in (
runtime / "dist" / "cli",
runtime / "node_modules",
runtime / "vendor",
runtime / "hooks" / "claude",
shared / "dist",
):
directory.mkdir(parents=True)
(runtime / "dist" / "cli" / "index.js").write_text("")
(runtime / "hooks" / "claude" / "resolve-analyze-cmd.cjs").write_text("")
(runtime / "package.json").write_text(json.dumps({"version": "9.9.9-test"}))
(runtime / "node_modules" / "gitnexus-shared").symlink_to(shared, target_is_directory=True)
(shared / "package.json").write_text(json.dumps({"name": "gitnexus-shared"}))
def test_runtime_mounts_reuse_primary_checkout_node_modules_from_a_worktree(
monkeypatch, tmp_path
) -> None:
primary = tmp_path / "primary"
worktree = tmp_path / "worktree"
_install_pinned_runtime(primary)
(primary / ".git" / "worktrees" / "wt").mkdir(parents=True)
_install_pinned_runtime(worktree)
shutil.rmtree(worktree / "gitnexus" / "node_modules")
(worktree / "gitnexus" / "node_modules").symlink_to(
primary / "gitnexus" / "node_modules",
target_is_directory=True,
)
(worktree / ".git").write_text(f"gitdir: {primary / '.git' / 'worktrees' / 'wt'}\n")
monkeypatch.setattr(runtime_mounts, "HARNESS_ROOT", worktree)
mounts = runner.trusted_gitnexus_runtime_mounts()
by_target = {mount.target: mount.source for mount in mounts}
assert by_target[f"{runner.SANDBOX_GITNEXUS}/node_modules"] == (
primary / "gitnexus" / "node_modules"
)
assert by_target[f"{runner.SANDBOX_GITNEXUS_SHARED}/package.json"] == (
primary / "gitnexus-shared" / "package.json"
)
assert by_target[f"{runner.SANDBOX_GITNEXUS}/dist"] == worktree / "gitnexus" / "dist"
def test_runtime_mounts_reuse_primary_shared_when_only_the_inner_link_points_there(
monkeypatch, tmp_path
) -> None:
primary = tmp_path / "primary"
worktree = tmp_path / "worktree"
_install_pinned_runtime(primary)
(primary / ".git" / "worktrees" / "wt").mkdir(parents=True)
_install_pinned_runtime(worktree)
linked = worktree / "gitnexus" / "node_modules" / "gitnexus-shared"
linked.unlink()
linked.symlink_to(primary / "gitnexus-shared", target_is_directory=True)
(worktree / ".git").write_text(f"gitdir: {primary / '.git' / 'worktrees' / 'wt'}\n")
monkeypatch.setattr(runtime_mounts, "HARNESS_ROOT", worktree)
mounts = runner.trusted_gitnexus_runtime_mounts()
by_target = {mount.target: mount.source for mount in mounts}
assert by_target[f"{runner.SANDBOX_GITNEXUS}/node_modules"] == (
worktree / "gitnexus" / "node_modules"
)
assert by_target[f"{runner.SANDBOX_GITNEXUS_SHARED}/package.json"] == (
primary / "gitnexus-shared" / "package.json"
)
def test_runtime_mounts_reject_a_node_modules_symlink_outside_the_primary_checkout(
monkeypatch, tmp_path
) -> None:
primary = tmp_path / "primary"
worktree = tmp_path / "worktree"
outsider = tmp_path / "outsider"
_install_pinned_runtime(primary)
_install_pinned_runtime(outsider)
(primary / ".git" / "worktrees" / "wt").mkdir(parents=True)
_install_pinned_runtime(worktree)
shutil.rmtree(worktree / "gitnexus" / "node_modules")
(worktree / "gitnexus" / "node_modules").symlink_to(
outsider / "gitnexus" / "node_modules",
target_is_directory=True,
)
(worktree / ".git").write_text(f"gitdir: {primary / '.git' / 'worktrees' / 'wt'}\n")
monkeypatch.setattr(runtime_mounts, "HARNESS_ROOT", worktree)
with pytest.raises(SandboxError, match="primary checkout"):
runner.trusted_gitnexus_runtime_mounts()
def test_runtime_mounts_reject_a_node_modules_symlink_in_a_regular_checkout(
monkeypatch, tmp_path
) -> None:
checkout = tmp_path / "checkout"
other = tmp_path / "other"
_install_pinned_runtime(checkout)
_install_pinned_runtime(other)
(checkout / ".git").mkdir()
shutil.rmtree(checkout / "gitnexus" / "node_modules")
(checkout / "gitnexus" / "node_modules").symlink_to(
other / "gitnexus" / "node_modules",
target_is_directory=True,
)
monkeypatch.setattr(runtime_mounts, "HARNESS_ROOT", checkout)
with pytest.raises(SandboxError, match="must be a real directory"):
runner.trusted_gitnexus_runtime_mounts()
@pytest.mark.skipif( @pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1", os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job", reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
@ -603,11 +458,7 @@ def test_real_bubblewrap_runtime_mount_imports_cli_without_exposing_checkout(tmp
assert visibility.ok, visibility.stderr_tail assert visibility.ok, visibility.stderr_tail
assert imported.ok, imported.stderr_tail assert imported.ok, imported.stderr_tail
assert analyze_imported.ok, analyze_imported.stderr_tail assert analyze_imported.ok, analyze_imported.stderr_tail
# The runtime the sandbox sees must be the one this checkout built — assert imported.stdout_tail.strip() == runner.PINNED_GITNEXUS_VERSION
# compared against the checkout itself rather than a constant, so a release
# bump cannot fail a benchmark that is running exactly what it should.
built = json.loads((runtime_mounts.HARNESS_ROOT / "gitnexus" / "package.json").read_text())
assert imported.stdout_tail.strip() == built["version"]
def test_isolated_mcp_registry_contains_only_the_sandbox_clone(tmp_path): def test_isolated_mcp_registry_contains_only_the_sandbox_clone(tmp_path):
@ -790,23 +641,6 @@ def test_skill_invocation_parses_supported_exact_identifier_fields(skill_input):
) )
def test_skill_invocation_accepts_plugin_qualified_identifier():
assert (
runner_sessions.skill_was_invoked_events(
skill_events({"skill": "compound-engineering:ce-code-review"}),
"ce-code-review",
)
is True
)
assert (
runner_sessions.skill_was_invoked_events(
skill_events({"skill": "compound-engineering:ce-plan"}),
"ce-code-review",
)
is False
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"skill_input", "skill_input",
[ [
@ -1133,35 +967,6 @@ def test_final_result_event_must_be_last(monkeypatch, tmp_path):
assert "not the last event" in rec["error_detail"]["event_stream_error"] assert "not the last event" in rec["error_detail"]["event_stream_error"]
def test_background_task_teardown_after_the_result_stays_valid_evidence(monkeypatch, tmp_path):
# Claude Code drains background-task bookkeeping after the final result
# event. Those `system` events carry no tool or usage payload, so they must
# not invalidate an otherwise complete session (run 29907431284 lost three
# runs this way, and the gate demands zero excluded runs).
teardown = [
{"type": "system", "subtype": "background_tasks_changed", "tasks": []},
{"type": "system", "subtype": "task_updated", "task_id": "bdw43oy7j", "patch": {"status": "killed"}},
{"type": "system", "subtype": "task_notification", "task_id": "bdw43oy7j", "status": "stopped"},
]
stream = event_stream(*skill_events({"skill": "gitnexus-work"})) + "".join(
json.dumps(event) + "\n" for event in teardown
)
monkeypatch.setattr(runner_sessions, "run_managed", lambda *a, **k: fake_cli_result(stream))
rec = runner.run_claude(
"task",
tmp_path,
claude_bin="claude",
timeout=5,
expected_skill="gitnexus-work",
)
assert rec["ok"] is True
assert rec["error_kind"] is None
assert rec["skill_invoked"] is True
assert rec["transcript_missing"] is False
assert "evidence_diagnostics" not in rec
def test_snapshot_plan_docs_detects_one_modified_plan_and_rejects_ambiguous_output(tmp_path): def test_snapshot_plan_docs_detects_one_modified_plan_and_rejects_ambiguous_output(tmp_path):
plans = tmp_path / "docs" / "plans" plans = tmp_path / "docs" / "plans"
plans.mkdir(parents=True) plans.mkdir(parents=True)
@ -1254,7 +1059,7 @@ def test_planning_cannot_change_source_tests_or_downstream_skill(monkeypatch, tm
@pytest.mark.parametrize( @pytest.mark.parametrize(
("attack", "expected_detail"), ("attack", "expected_detail"),
[ [
("workspace", "changed the read-only workspace"), ("workspace", "unauthorized workspace path"),
("skill", "changed the evaluated skill fingerprint"), ("skill", "changed the evaluated skill fingerprint"),
], ],
) )
@ -1270,11 +1075,7 @@ def test_review_phase_rejects_workspace_or_skill_mutation(
expected_skill_digest = "expected-skill-fingerprint" expected_skill_digest = "expected-skill-fingerprint"
def adversarial_review(prompt, *args, **kwargs): def adversarial_review(prompt, *args, **kwargs):
# Write where the contract now says: the artifact directory outside the (tmp_path / "review-output.md").write_text("review findings")
# workspace, which is the only place the agent can write atomically.
artifact = runner.review_output_path(sandbox, runner.REVIEW_OUTPUT)
artifact.parent.mkdir(parents=True, exist_ok=True)
artifact.write_text('{"schema_version":1,"verdict":"approve","findings":[]}')
if attack == "workspace": if attack == "workspace":
source.write_text("review silently changed source") source.write_text("review silently changed source")
return session_record() return session_record()
@ -1305,97 +1106,6 @@ def test_review_phase_rejects_workspace_or_skill_mutation(
assert expected_detail in rec["error_detail"] assert expected_detail in rec["error_detail"]
def test_a_cancelled_clone_copy_does_not_fall_back_to_an_uncancellable_copytree(monkeypatch, tmp_path):
"""The reflink fallback is for a filesystem, not for a teardown.
run_managed reports cancellation as a non-OK result rather than raising, so
the fallback treated it like an unsupported reflink and started a copytree
that cannot be cancelled — waiting out exactly the full copy the outage
breaker set the cancellation event to avoid.
"""
source = tmp_path / "template"
(source / ".git").mkdir(parents=True)
parent = tmp_path / "clones"
parent.mkdir()
copied: list[object] = []
monkeypatch.setattr(
runner_artifacts,
"run_managed",
lambda *_a, **_k: ManagedProcessResult(
state="cancelled",
returncode=None,
stdout_tail="",
stderr_tail="",
duration_s=0.1,
),
)
monkeypatch.setattr(runner_artifacts.shutil, "copytree", lambda *a, **k: copied.append(a))
with pytest.raises(ManagedProcessError):
runner.copy_isolated_tree(source, parent)
assert copied == []
assert list(parent.iterdir()) == [], "the partial target must be cleaned up"
@pytest.mark.parametrize("arm", ["review", "ce_review"])
def test_run_arm_mounts_the_review_artifact_directory_outside_the_workspace(monkeypatch, tmp_path, arm):
"""A writable FILE inside a read-only directory is not a writable path.
The Write tool creates `<target>.tmp.<n>.<hex>` beside the target and
renames it, so a read-only parent fails the temp create with EROFS and the
artifact stays 0 bytes. The mount target must be the directory, and it must
sit outside the read-only workspace.
Driven through run_arm rather than rebuilt here: an expected tuple assembled
in the test passes whatever run_arm actually mounts, which is the one thing
this needs to prove.
"""
assert not runner.SANDBOX_REVIEW_OUTPUT.startswith(runner.SANDBOX_WORKSPACE + "/")
assert runner.SANDBOX_REVIEW_OUTPUT != runner.SANDBOX_WORKSPACE
verify_calls: list[dict] = []
sandbox = fake_sandbox(tmp_path)
sandbox.command_prefix_for = lambda **kwargs: verify_calls.append(kwargs) or []
def review_session(prompt, *args, **kwargs):
artifact = runner.review_output_path(sandbox, runner.REVIEW_OUTPUT)
artifact.parent.mkdir(parents=True, exist_ok=True)
artifact.write_text('{"schema_version":1,"verdict":"approve","findings":[]}')
return session_record()
monkeypatch.setattr(runner, "run_claude", review_session)
monkeypatch.setattr(runner, "skill_fingerprint", lambda *_a, **_k: "skill-digest")
monkeypatch.setattr(runner, "run_verify", lambda *a, **k: (True, "ok"))
runner.run_arm(
arm,
{"prompt": "p", "verify": "true"},
tmp_path,
bench_args(),
sandbox=sandbox,
expected_skill_digest="skill-digest",
)
review_output = runner.review_output_path(sandbox, runner.REVIEW_OUTPUT)
expected = (runner.ReadOnlyMount(source=review_output.parent, target=runner.SANDBOX_REVIEW_OUTPUT),)
# The EROFS bug is about the AGENT's write, so the mount that has to be the
# directory is the writable one on the review session — not the read-only
# exposure the verify command gets afterwards. Assert both: they are
# separate arguments to separate command prefixes.
writable = [call["extra_writable_mounts"] for call in verify_calls if "extra_writable_mounts" in call]
assert writable, "the review session must be given a writable artifact mount"
assert writable[-1] == expected, "mount the directory, not the file"
read_only = [call["extra_read_only_mounts"] for call in verify_calls if "extra_read_only_mounts" in call]
assert read_only, "the verify invocation must be given the artifact mount"
assert read_only[-1] == expected, "mount the directory, not the file"
assert not expected[0].target.startswith(f"{runner.SANDBOX_WORKSPACE}/")
# The artifact the harness later reads is the one inside that mount.
assert review_output.parent in review_output.parents
def _git(repo, *args, check=True): def _git(repo, *args, check=True):
return subprocess.run(["git", "-C", str(repo), *args], check=check, capture_output=True, text=True) return subprocess.run(["git", "-C", str(repo), *args], check=check, capture_output=True, text=True)
@ -1446,34 +1156,3 @@ def test_make_worktree_clone_has_no_tags_but_keeps_all_branches(tmp_path):
current = _git(target, "rev-parse", "HEAD").stdout.strip() current = _git(target, "rev-parse", "HEAD").stdout.strip()
assert current == other_sha assert current == other_sha
def test_copy_isolated_tree_does_not_share_git_objects_or_refs(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "--quiet")
_git(repo, "checkout", "--quiet", "-b", "main")
sha = _git_commit(repo, "base")
clones = tmp_path / "clones"
clones.mkdir()
template = runner.make_worktree(repo, sha, clones)
(template / "marker.txt").write_text("template\n")
copy = runner.copy_isolated_tree(template, clones)
assert copy != template
assert (copy / "marker.txt").read_text() == "template\n"
(copy / "marker.txt").write_text("copy\n")
assert (template / "marker.txt").read_text() == "template\n"
copy_head = _git(copy, "rev-parse", "HEAD").stdout.strip()
template_head = _git(template, "rev-parse", "HEAD").stdout.strip()
assert copy_head == template_head == sha
# An equal initial HEAD is also what a shared ref namespace looks like, so
# write a ref and prove the template cannot see it. A linked worktree would
# pass every assertion above, including the alternates check — its `.git` is
# a file, so the directory inspected below simply does not exist.
_git(copy, "branch", "copy-only")
assert _git(copy, "show-ref", "--verify", "refs/heads/copy-only").returncode == 0
assert _git(template, "show-ref", "--verify", "refs/heads/copy-only", check=False).returncode != 0
assert (copy / ".git").is_dir()
alternates = copy / ".git" / "objects" / "info" / "alternates"
assert not alternates.exists()

1412
eval/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
# Skill benchmark — evolve review quality, measure workflow cost # Workflow benchmark — observe the token savings
Measures whether the `gitnexus-plan` → `gitnexus-work` engineering workflow Measures whether the `gitnexus-plan` → `gitnexus-work` engineering workflow
actually saves tokens versus a baseline agent on the same tasks, using real actually saves tokens versus a baseline agent on the same tasks, using real
@ -8,19 +8,18 @@ report.
## What it compares ## What it compares
| Arm | Sessions | Notes | | Arm | Sessions | Notes |
| --------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | --- | --- | --- |
| `workflow` | `gitnexus-plan` on the task, then `gitnexus-work` on the produced plan | The skills must be installed (`gitnexus setup`, or repo-local `.claude/skills/`) | | `workflow` | `gitnexus-plan` on the task, then `gitnexus-work` on the produced plan | The skills must be installed (`gitnexus setup`, or repo-local `.claude/skills/`) |
| `candidate_workflow` | same sessions as `workflow`, with a candidate skill overlay | Paired with `workflow` on the same task/ref/model | | `candidate_workflow` | same sessions as `workflow`, with a candidate skill overlay | Paired with `workflow` on the same task/ref/model |
| `workflow_direct` | one `gitnexus-work` direct-mode session | The middle option — execution discipline without a planning pass | | `workflow_direct` | one `gitnexus-work` direct-mode session | The middle option — execution discipline without a planning pass |
| `candidate_workflow_direct` | same session as `workflow_direct`, with a candidate skill overlay | Paired with `workflow_direct` on the same task/ref/model | | `candidate_workflow_direct` | same session as `workflow_direct`, with a candidate skill overlay | Paired with `workflow_direct` on the same task/ref/model |
| `ce_workflow` | `ce-plan` on the task, then `ce-work` on the produced plan | External comparator: the explicitly supplied, pinned compound-engineering plugin's plan→work family | | `ce_workflow` | `ce-plan` on the task, then `ce-work` on the produced plan | External comparator: the explicitly supplied, pinned compound-engineering plugin's plan→work family |
| `ce_workflow_direct` | one `ce-work` direct-mode session | External comparator paired with `workflow_direct` | | `ce_workflow_direct` | one `ce-work` direct-mode session | External comparator paired with `workflow_direct` |
| `review` | one `gitnexus-review` session over an immutable historical PR snapshot | Emits strict `review-output.json`; hidden human labels score quality after the session | | `review` | one `gitnexus-review` session over local uncommitted changes | The task's `setup` applies the diff under review; the review is written to `review-output.md` so `verify` can gate on it |
| `candidate_review` | the same review with a `gitnexus-review` candidate overlay | Paired with `review` on the same case/ref/model/runtime | | `ce_review` | one `ce-code-review` session over the same changes | External comparator paired with `review` |
| `ce_review` | one pinned `ce-code-review` session over the same changes | External comparator paired with both review arms | | `baseline` | one session with the identical task text | `--disallowedTools Skill` so it cannot borrow the workflow; same repo, same MCP tools |
| `baseline` | one session with the identical task text | `--disallowedTools Skill` so it cannot borrow the workflow; same repo, same MCP tools | | `baseline_nomcp` | like baseline, graph tools also disallowed | Separates the workflow-discipline question from the GitNexus-tools question (off by default) |
| `baseline_nomcp` | like baseline, graph tools also disallowed | Separates the workflow-discipline question from the GitNexus-tools question (off by default) |
Every arm runs in a fresh detached git worktree of the task's `ref`, once per Every arm runs in a fresh detached git worktree of the task's `ref`, once per
`--runs`. The model-visible `verify` command is recorded as `--runs`. The model-visible `verify` command is recorded as
@ -37,7 +36,7 @@ lfg's gate and work's direct-mode triage should encode.
```bash ```bash
cd eval cd eval
export GITNEXUS_BENCH_ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" export GITNEXUS_BENCH_AUTH_TOKEN="$ANTHROPIC_API_KEY"
uv run --locked --extra dev python -m workflow_bench.runner \ uv run --locked --extra dev python -m workflow_bench.runner \
--tasks workflow_bench/tasks.scenarios.yaml --runs 3 \ --tasks workflow_bench/tasks.scenarios.yaml --runs 3 \
--model claude-sonnet-4-20250514 --model claude-sonnet-4-20250514
@ -121,15 +120,10 @@ digest. Files written beneath the agent's `$HOME` are never trusted as
evidence. evidence.
Bare mode is deliberately non-interactive: it does not consult a stored Bare mode is deliberately non-interactive: it does not consult a stored
Claude login/keychain or `ANTHROPIC_AUTH_TOKEN`. Supply an Anthropic API key Claude login/keychain or `ANTHROPIC_AUTH_TOKEN`. Supply one explicit API or
through `GITNEXUS_BENCH_ANTHROPIC_API_KEY` (preferred) or `--anthropic-api-key`; proxy key through `GITNEXUS_BENCH_AUTH_TOKEN` (preferred) or `--auth-token`;
the harness maps it to `ANTHROPIC_API_KEY` only for the trusted Claude parent the harness maps it to `ANTHROPIC_API_KEY` only for the trusted Claude parent
and scrubs it from agent-launched tools. `GITNEXUS_BENCH_AUTH_TOKEN` and and scrubs it from agent-launched tools.
`--auth-token` remain as aliases. OpenAI keys are not a drop-in
replacement: pass `--openai-api-key` / `GITNEXUS_BENCH_OPENAI_API_KEY` with
`gpt-*` / `o*` / `openai/*` model ids and the harness starts a loopback
LiteLLM proxy. The OpenAI key stays on that host process; Claude still sees
only a minted `ANTHROPIC_API_KEY` plus `ANTHROPIC_BASE_URL`.
The trusted Claude CLI still needs outbound access to the explicitly supplied The trusted Claude CLI still needs outbound access to the explicitly supplied
model endpoint. This is not a network broker, so the CLI itself retains that model endpoint. This is not a network broker, so the CLI itself retains that
@ -139,30 +133,6 @@ Native benchmark execution is therefore Linux/WSL2-only. Evidence assembly
and hand-authored overlay preparation can happen elsewhere, but and hand-authored overlay preparation can happen elsewhere, but
`--initial-overlay` does not bypass containment. `--initial-overlay` does not bypass containment.
For a local diagnostic inside a container that blocks user namespaces, an
operator may explicitly choose the non-containment host backend:
```bash
UNSAFE_NO_BWRAP=1 RUNS=1 ./workflow_bench/run-evolution.sh
```
This mode runs review sessions directly in disposable host worktrees and is
**not** a security boundary: it does not isolate the network or create a PID
namespace, and a session that can `chmod` can undo the workspace lock. The
harness drops write bits on the whole clone, with no carve-out, so accidental
`npm install` / analyze writes cannot invalidate review evidence. The review
artifact is not in the clone at all: it lives in a writable directory bound at
`/review-output`, outside the workspace.
Sandbox cleanup restores owner write bits before deleting the private TMPDIR,
because a session that `copytree`s the locked clone would otherwise leave
non-empty 0555 directories that `rmtree` cannot remove. Historical review
SHAs that gitignore `.claude/skills/*` are force-added when the harness seeds
or overlays the evaluated `gitnexus-review` skill.
Treat model and verifier processes as able to access host files and
credentials available to the invoking user. It is restricted to the review
benchmark, forbidden with `--apply` and whenever `CI` is set;
promotion-capable and CI runs must use Bubblewrap.
## Prompt and skill evolution loop ## Prompt and skill evolution loop
Prompts age as models and tool harnesses change. Treat the current skills and Prompts age as models and tool harnesses change. Treat the current skills and
@ -170,36 +140,6 @@ router thresholds as an incumbent policy, not permanent truth. Candidate
changes run offline in the same throwaway clones as the incumbent; production changes run offline in the same throwaway clones as the incumbent; production
skills never rewrite themselves from a live task. skills never rewrite themselves from a live task.
On the self-hosted evolution box, `run-evolution.sh` passes
`--max-runtime-from-instance-window` and the CLI derives its own cap from
`/proc/uptime` at startup (24h EventBridge window minus a 90-minute upload
reserve), in the same breath as it starts the clock that cap is measured
against — a budget computed anywhere earlier is spent by the seconds between. A `workflow_dispatch` that lands on an
already-running instance therefore exits in-process instead of vanishing when
the box stops — a cancelled GitHub job skips even `if: always()`, which is
how run 33962002890 lost 51 finished sessions. Local runs are uncapped.
A review generation is 6 tasks × 3 arms × 3 runs. Serial workers=1 at ~19
minutes per session is a 16-hour job (run 33962002890). Two harness changes
cut that without shrinking the gate:
- **Comparator reuse.** `evolve.py` forwards the seed / prior generation as
`--reuse-results`. Incumbent `review` and `ce_review` rows are copied into
the new `results.jsonl` when model, effort, task SHA, prompt digest, oracle
bytes, incumbent skill digest, CE plugin digest, and sandbox backend still
match. Candidate arms always run. A weekly generation with an unchanged
incumbent therefore pays 18 sessions, not 54. A promotion, model change,
task-corpus change, or harness `RUNTIME_DIGEST` change invalidates the
lock and re-runs the comparators.
- **Sanitized clone templates.** Each unique task SHA is cloned and
sanitized once. Cells copy that parentless snapshot (reflink when the
filesystem allows) instead of `git clone --no-local` plus repack/prune/fsck
54 times. Isolation is a private `.git`, not a second copy of full history.
Dispatch defaults to `--workers 3` so those 18 paid cells can overlap. Size
workers to the host: a cell that loses CPU and hits the session ceiling is
an excluded run the gate refuses.
Build an overlay that mirrors only the canonical repo-local skill paths: Build an overlay that mirrors only the canonical repo-local skill paths:
```text ```text
@ -221,7 +161,7 @@ paid work. For a work overlay:
cd eval cd eval
uv run --locked --extra dev python -m workflow_bench.runner \ uv run --locked --extra dev python -m workflow_bench.runner \
--tasks workflow_bench/tasks.scenarios.yaml \ --tasks workflow_bench/tasks.scenarios.yaml \
--runs 3 --workers 1 --model claude-sonnet-4-20250514 \ --runs 3 --model claude-sonnet-4-20250514 \
--arms workflow candidate_workflow \ --arms workflow candidate_workflow \
workflow_direct candidate_workflow_direct \ workflow_direct candidate_workflow_direct \
--candidate-overlay /tmp/gn-skill-candidate --candidate-overlay /tmp/gn-skill-candidate
@ -237,7 +177,7 @@ artifacts. Those artifacts are the trajectory evidence: cluster failures and
expensive detours, propose one bounded prompt change, and feed it back as the expensive detours, propose one bounded prompt change, and feed it back as the
next overlay. next overlay.
When candidate arms are present the runner also writes schema-6 When candidate arms are present the runner also writes schema-3
`promotion.json`. It `promotion.json`. It
binds the immutable overlay digest, benchmark model, truthful candidate origin binds the immutable overlay digest, benchmark model, truthful candidate origin
(a named proposer model or `manual-initial-overlay`), selected (a named proposer model or `manual-initial-overlay`), selected
@ -246,36 +186,9 @@ immutable dependency bytes, committed base digest of every apply
destination, exact required arms, thresholds, and evidence expiry. Its default destination, exact required arms, thresholds, and evidence expiry. Its default
deterministic gate is deliberately conservative: deterministic gate is deliberately conservative:
Schema 6 binds a separate policy to each required candidate arm and records
whether the sweep completed. Apply validates the paired metrics and recomputes
each decision. Historical schema 5 reports remain readable; regenerate their
benchmark evidence before applying an overlay. Editing a schema number does
not supply the missing evidence.
Review candidates optimize weighted F1 with a minimum improvement of 0.01,
complete paired evidence on every selected task, and no per-task quality
regression. Complete misses score zero. Matching uses maximum cardinality
throughout the 100-finding limit. Downgraded findings receive at most their
reported severity's weight; only blocking-severity matches count toward blocker
recall. Every valid candidate repeat must have the correct verdict, and the
minimum blocker recall across repeats must not regress. Clean controls retain
their false-positive and verdict safeguards. Implementation candidates retain
the efficiency policy below:
- at least 3 paired VALID runs per task, zero excluded runs in either arm - at least 3 paired VALID runs per task, zero excluded runs in either arm
(session/infra-error rows therefore block promotion), and a named model; (session/infra-error rows therefore block promotion), and a named model;
- a fully measured task that neither arm ever resolves remains reported but is - the candidate must pass the hidden oracle on every valid run for every task;
ungated from the quality comparison — only if its metric was measured in both
arms and no run hit `skill-not-invoked` (a skill that never loaded is prompt
evidence, not task health). An ungated task still ranks against a looser 100%
failed-task regression cap on the promotion metric;
- at least half the paired tasks must stay gated, and `promotion.json` discloses
the gated/ungated split per decision; a set with no gated task at all is
`insufficient_evidence`;
- the candidate must pass the hidden oracle on every valid run of every gated
task the incumbent resolves at least once — on a task the incumbent never
resolves, partial candidate progress counts as improvement instead of failing
the floor, so making some progress is never scored worse than making none;
- no per-task resolution-rate regression (quality is lexicographically first); - no per-task resolution-rate regression (quality is lexicographically first);
- promotion by resolution needs a margin of at least 2 resolved runs — - promotion by resolution needs a margin of at least 2 resolved runs —
a 1-run difference is noise at this run count and falls through to the a 1-run difference is noise at this run count and falls through to the
@ -306,85 +219,28 @@ without weakening today's deterministic promotion boundary.
### Closing the loop automatically (`evolve.py`) ### Closing the loop automatically (`evolve.py`)
The evolution workflow runs an offline containment preflight with the pinned
Claude Code 2.1.214 binary before starting a paid proposer or benchmark. The
review canary seals the workspace read-only and writes nothing into it: the
artifact directory is bound at `/review-output` outside the workspace, and the
file itself is deliberately absent until the session creates it, so its absence
distinguishes "never written" from "written badly". Runtime mount placeholders
are prepared in the disposable clone before sealing it; existing config bytes
are preserved.
Any pre-existing result entry, including a symlink, is rejected. Required
canaries fail when their runtime or Bubblewrap is unavailable.
The default outage limit is five consecutive unusable results, across task
boundaries. Invalid review JSON advances this limit even when a skill or session
error was recorded first. A valid zero-quality review resets it. Concurrent
waves can exceed the limit by at most `workers - 1` completed cells; no further
wave starts after a trip. Completed rows and redacted diagnostics remain in the
partial report, the runner exits nonzero, and the evolution driver stops without
applying or starting another generation.
SIGINT and SIGTERM propagate one cancellation event through managed commands,
including clone, setup, Claude, and verification. Executor submissions copy
the run context so indirect subprocess helpers receive the same event. Active
process groups or Windows Job Objects are terminated and workers joined before
shared assets or the gateway are released. Controlled cancellation tests require
cleanup within 15 seconds. Cancellation remains distinct from timeout and
quality failure in recorded evidence.
The gateway runs under a private supervisor watching a pipe owned only by the
harness. Parent exit, including SIGKILL, closes that pipe and stops the proxy
group; Windows also retains kill-on-close Job Object ownership. Keep completed
JSONL rows, transcripts, the partial report, and gateway diagnostics when
investigating an interrupted run. A subsequent paid comparison needs fresh
evidence from all arms under the same dependency lock. LiteLLM pricing comes
from that locked release's local cost map; compare no old/new-lock costs as
quality evidence.
`workflow_bench.evolve` automates the three manual arrows — propose, `workflow_bench.evolve` automates the three manual arrows — propose,
benchmark, apply — without moving the trust boundary: benchmark, apply — without moving the trust boundary:
```bash ```bash
cd eval cd eval
./workflow_bench/run-evolution.sh # local; no working-tree apply uv run --locked --extra dev python -m workflow_bench.evolve \
./workflow_bench/run-evolution.sh --apply # CI; same argv the workflow uses --tasks workflow_bench/tasks.scenarios.yaml \
./workflow_bench/run-evolution.sh --dry-run # print the evolve command --model claude-sonnet-4-20250514 --generations 2 \
--seed-results results/wfbench-<prior-run> # optional gen-0 evidence
``` ```
The GitHub skill-evolution job calls this script. Do not invoke Each generation: a confined **proposer** session reads the incumbent plan/work
`python -m workflow_bench.evolve` directly for a full loop. Environment knobs skills, the prior generation's `results.jsonl`
match the workflow: `MODEL`, `PROPOSER_MODEL`, `GENERATIONS`, `RUNS`, loser rows, their session transcripts and patches, and the learning queue,
`WORKERS`, `PROVIDER`, `EFFORT`, `SEED_RESULTS`, `INCLUDE_EXPENSIVE`. The
checked-in production defaults are `PROVIDER=openai`, `MODEL=gpt-5.6-sol`,
`PROPOSER_MODEL=gpt-5.6-sol`, and `EFFORT=xhigh`.
The scheduled/default profile is read-only review evolution. Set
`EVOLUTION_PROFILE=implementation` explicitly to run the legacy plan/work
benchmark. Review mode requires `CE_PLUGIN_DIR` and `CE_PLUGIN_VERSION`.
Each review generation: a confined **proposer** session reads only the incumbent
`gitnexus-review` skill, normalized CE/incumbent/candidate result rows, bounded
review artifacts and session transcripts, and the rejected
`proposal.md` when available (including a workflow seed from a prior run), and
the learning queue,
then writes ONE bounded candidate overlay plus a reviewer-facing then writes ONE bounded candidate overlay plus a reviewer-facing
`proposal.md`. The proposer's clone is sanitized exactly like an arm's before `proposal.md`. The overlay is re-validated by `candidate_overlay_files`
its session starts: it authors the artifact the arms are scored with, so (same boundary: Markdown under the plan/work trees, nothing else), frozen,
letting it read `eval/workflow_bench` would hand it the task prompts and the
hidden oracles it is about to be graded against, and a proposal could win the
gate by encoding the expected behavior into a skill rather than by being a
better skill. The overlay is re-validated by `candidate_overlay_files`
(same boundary: Markdown under `gitnexus-review`, including exercised
`ci-personas/`, nothing else), frozen,
and exercised only by its exact required pairs. Task refs are resolved once and exercised only by its exact required pairs. Task refs are resolved once
before generation zero and the immutable task bindings are forwarded to every before generation zero and the immutable task bindings are forwarded to every
generated runner invocation, so a moving branch cannot change later evidence. generated runner invocation, so a moving branch cannot change later evidence.
The deterministic quality-first gate rejects blocker-recall regressions, The deterministic gate then decides. Promotion application rejects older
new false positives on clean controls, and any weighted-score regression. pre-oracle evidence schemas. `promote` stops the loop; with `--apply`
Repeated evidence (`RUNS>=3`) is required for promotion; `RUNS=1` is
diagnostic-only. CE is the external comparator. Cost and latency are
tiebreakers and never compensate for quality loss. `promote` stops the loop; with `--apply`
the authorized frozen bytes the authorized frozen bytes
are transactionally applied to the canonical are transactionally applied to the canonical
`.claude/skills/` trees and their shipped mirrors as an ordinary `.claude/skills/` trees and their shipped mirrors as an ordinary
@ -394,19 +250,17 @@ generation's trajectories to the next proposer. `--initial-overlay` skips
the generation-0 proposer to benchmark a hand-written candidate; the generation-0 proposer to benchmark a hand-written candidate;
`--proposer-model` upgrades only the diagnosis session. `--proposer-model` upgrades only the diagnosis session.
**Learning queue.** Live skill runs never self-edit (see each **Learning queue.** Live plan/work skill runs never self-edit (see each
skill's "Skill feedback" section) — instead they may append one-line JSON notes to skill's "Skill feedback" section) — instead they may append one-line JSON notes to
`workflow_bench/learnings.jsonl` (gitignored, machine-local like the `workflow_bench/learnings.jsonl` (gitignored, machine-local like the
transcripts they complement). The proposer reads the queue as hints, not transcripts they complement). The proposer reads the queue as hints, not
ground truth: a learning only reaches a shipped skill by surviving the same ground truth: a learning only reaches a shipped skill by surviving the same
paired benchmark as any other candidate. paired benchmark as any other candidate. Legacy review/LFG rows are ignored;
those skills do not yet have honest candidate lanes or promotion gates.
For ad-hoc use, run the driver on the existing re-evaluation triggers Run the driver on the existing re-evaluation triggers (model/harness change,
(model/harness change or 90-day staleness). The repository workflow runs a 90-day staleness), not on a tight schedule — every generation costs ≥3 paired
deliberate weekly drift check: dispatch defaults to three concurrent cells runs per task, and `--generations` is the only loop bound.
of one task; scheduled concurrency still requires
`GITNEXUS_EVOLUTION_WORKERS=3` after a clean proof. `--workers` is bounded
to 1–8 before paid work starts. `--generations` remains the only loop bound.
## Free-model setup (no paid tokens) ## Free-model setup (no paid tokens)
@ -425,30 +279,12 @@ uv run --locked --with 'litellm[proxy]' litellm --config workflow_bench/free-mod
# 2. Point the benchmark at it # 2. Point the benchmark at it
uv run --locked --extra dev python -m workflow_bench.runner \ uv run --locked --extra dev python -m workflow_bench.runner \
--tasks workflow_bench/tasks.scenarios.yaml --runs 3 \ --tasks workflow_bench/tasks.scenarios.yaml --runs 3 \
--base-url http://localhost:4000 --anthropic-api-key "$LITELLM_MASTER_KEY" --model free-coder --base-url http://localhost:4000 --auth-token "$LITELLM_MASTER_KEY" --model free-coder
``` ```
## OpenAI API keys
Claude Code still speaks Anthropic `/v1/messages`. For a paid OpenAI backend,
do not point `--anthropic-api-key` at an `sk-...` OpenAI key. Export the OpenAI key
and use OpenAI model ids; the driver starts the proxy itself:
```bash
export GITNEXUS_BENCH_OPENAI_API_KEY="$OPENAI_API_KEY"
PROVIDER=openai ./workflow_bench/run-evolution.sh
```
The GitHub skill-evolution workflow accepts `GITNEXUS_BENCH_OPENAI_API_KEY` on
the `gitnexus-evolution` environment. Dispatch with `provider=openai` to force
that backend even when an Anthropic token is also configured (otherwise `auto`
keeps using Anthropic whenever that secret exists). Claude default model
inputs are then rewritten to `gpt-5.6-sol`; every proposer and benchmark
session receives `--effort xhigh`.
Caveats, honestly: Caveats, honestly:
- Both arms run on the same model, so the _comparison_ stays fair at any - Both arms run on the same model, so the *comparison* stays fair at any
quality level — but small free models follow skills less reliably, so quality level — but small free models follow skills less reliably, so
expect lower resolve rates and noisier savings than on frontier models. expect lower resolve rates and noisier savings than on frontier models.
Treat free-model runs as directional; confirm headline numbers with a Treat free-model runs as directional; confirm headline numbers with a
@ -475,16 +311,16 @@ Three task classes × three arms, single-repo (GitNexus itself). **Every arm
resolved every task** — at this difficulty, pass/fail quality is saturated resolved every task** — at this difficulty, pass/fail quality is saturated
and the comparison is pure cost: and the comparison is pure cost:
| task (class) | arm | resolved | cost $ | wall | turns | vs baseline cost | | task (class) | arm | resolved | cost $ | wall | turns | vs baseline cost |
| ----------------------------- | --------------- | -------- | ------ | ---- | ----- | ----------------------- | | --- | --- | --- | --- | --- | --- | --- |
| trivial-version-alias | workflow | 1/1 | 9.16 | 16m | 63 | −333% | | trivial-version-alias | workflow | 1/1 | 9.16 | 16m | 63 | −333% |
| trivial-version-alias | baseline | 1/1 | 2.11 | 2.8m | 16 | — | | trivial-version-alias | baseline | 1/1 | 2.11 | 2.8m | 16 | — |
| inv-bug-pdg-note | workflow | 1/1 | 14.56 | 21m | 83 | −331% | | inv-bug-pdg-note | workflow | 1/1 | 14.56 | 21m | 83 | −331% |
| inv-bug-pdg-note | workflow_direct | 1/1 | 5.23 | 7.5m | 32 | −55% | | inv-bug-pdg-note | workflow_direct | 1/1 | 5.23 | 7.5m | 32 | −55% |
| inv-bug-pdg-note | baseline | 1/1 | 3.38 | 4.7m | 22 | — | | inv-bug-pdg-note | baseline | 1/1 | 3.38 | 4.7m | 22 | — |
| inv-feature-list-repos-filter | workflow | 1/1 | 13.22 | 19m | 84 | −211% | | inv-feature-list-repos-filter | workflow | 1/1 | 13.22 | 19m | 84 | −211% |
| inv-feature-list-repos-filter | workflow_direct | 1/1 | 4.87 | 4.8m | 38 | −15% (wall +14% faster) | | inv-feature-list-repos-filter | workflow_direct | 1/1 | 4.87 | 4.8m | 38 | −15% (wall +14% faster) |
| inv-feature-list-repos-filter | baseline | 1/1 | 4.25 | 5.5m | 32 | — | | inv-feature-list-repos-filter | baseline | 1/1 | 4.25 | 5.5m | 32 | — |
What the ground base says, honestly: What the ground base says, honestly:
@ -499,7 +335,7 @@ What the ground base says, honestly:
detect_changes-before-commit) is cheap. It produced noticeably more test detect_changes-before-commit) is cheap. It produced noticeably more test
coverage than baseline for near-equal cost on the feature task. coverage than baseline for near-equal cost on the feature task.
- **Quality didn't differentiate because nothing failed.** The regime where - **Quality didn't differentiate because nothing failed.** The regime where
the workflow should win on _resolve rate_ — cross-module tasks where the workflow should win on *resolve rate* — cross-module tasks where
baselines flail — is the unmeasured cell (`cross-module-parse-retry`), and baselines flail — is the unmeasured cell (`cross-module-parse-retry`), and
the next thing to measure, ideally with `--runs 3+` on a free backend. the next thing to measure, ideally with `--runs 3+` on a free backend.
- Caveats: n=1 per cell, one repo, one model; churn numbers from this run - Caveats: n=1 per cell, one repo, one model; churn numbers from this run
@ -517,11 +353,11 @@ If a future run shows the workflow flattering itself here, distrust the run.
The hardest class — retry-with-backoff across the worker-pool/pipeline The hardest class — retry-with-backoff across the worker-pool/pipeline
seams, transient-vs-deterministic classification: seams, transient-vs-deterministic classification:
| arm | resolved | cost $ | wall | turns | churn | | arm | resolved | cost $ | wall | turns | churn |
| ------------------- | -------- | -------- | ------- | ------ | ----------- | | --- | --- | --- | --- | --- | --- |
| workflow | 1/1 | 18.32 | 37m | 107 | 4/+373/−17 | | workflow | 1/1 | 18.32 | 37m | 107 | 4/+373/−17 |
| **workflow_direct** | 1/1 | **9.53** | **15m** | **52** | 11/+244/−66 | | **workflow_direct** | 1/1 | **9.53** | **15m** | **52** | 11/+244/−66 |
| baseline | 1/1 | 18.03 | 34m | 98 | 6/+345/−69 | | baseline | 1/1 | 18.03 | 34m | 98 | 6/+345/−69 |
(The workflow_direct row is the clean re-run under clone isolation — the (The workflow_direct row is the clean re-run under clone isolation — the
original was contaminated, see the integrity note below.) original was contaminated, see the integrity note below.)
@ -555,14 +391,14 @@ category-priced freshness (`accept` for compact classes), per-category turn
budgets, and the work-phase HEAD==pin fast path, the same budgets, and the work-phase HEAD==pin fast path, the same
`inv-bug-pdg-note` workflow cell re-measured (n=1): `inv-bug-pdg-note` workflow cell re-measured (n=1):
| | ground base | optimized | delta | | | ground base | optimized | delta |
| ------------- | ----------- | --------- | -------- | | --- | --- | --- | --- |
| resolved | ✅ | ✅ | — | | resolved | ✅ | ✅ | — |
| cost $ | 14.56 | 11.70 | **−20%** | | cost $ | 14.56 | 11.70 | **−20%** |
| turns | 83 | 72 | −13% | | turns | 83 | 72 | −13% |
| output tokens | 59,789 | 53,345 | −11% | | output tokens | 59,789 | 53,345 | −11% |
| cache_read | 6.64M | 5.07M | −24% | | cache_read | 6.64M | 5.07M | −24% |
| wall | 21m | 25m | +15% | | wall | 21m | 25m | +15% |
Verified in-transcript: the compact form fired (115-line plan vs 209 for a Verified in-transcript: the compact form fired (115-line plan vs 209 for a
simpler task pre-optimization), the plan session dropped 72→49 turns, and simpler task pre-optimization), the plan session dropped 72→49 turns, and
@ -575,8 +411,8 @@ this task class, so the routing rule above stands unchanged.
## Writing good tasks ## Writing good tasks
See `tasks.scenarios.yaml`. Small enough to finish headless, real enough to See `tasks.scenarios.yaml`. Small enough to finish headless, real enough to
require investigation — the workflow's savings come from _not re-reading and require investigation — the workflow's savings come from *not re-reading and
not re-investigating_, which trivial tasks never exercise. Keep `verify` as a not re-investigating*, which trivial tasks never exercise. Keep `verify` as a
model-visible authored-test quality signal, and add an independent `oracle` model-visible authored-test quality signal, and add an independent `oracle`
whose source files live under `workflow_bench/oracles/`. Oracle commands must whose source files live under `workflow_bench/oracles/`. Oracle commands must
run only files staged beneath `$GITNEXUS_BENCH_ORACLE_ROOT`; for Vitest, include run only files staged beneath `$GITNEXUS_BENCH_ORACLE_ROOT`; for Vitest, include
@ -586,7 +422,7 @@ carry build pre-hooks).
## Relation to the SWE-bench harness ## Relation to the SWE-bench harness
The rest of `eval/` benchmarks GitNexus _tools_ inside a litellm agent loop The rest of `eval/` benchmarks GitNexus *tools* inside a litellm agent loop
(baseline vs graph-enhanced). This module benchmarks the _skill workflow_ (baseline vs graph-enhanced). This module benchmarks the *skill workflow*
inside the real CLI harness those skills ship for. Different question, same inside the real CLI harness those skills ship for. Different question, same
spirit: measure, don't assume. spirit: measure, don't assume.

View file

@ -1,604 +0,0 @@
"""Reuse frozen comparator cells when the current sweep is still the same experiment.
Weekly skill evolution re-runs incumbent ``review`` / ``ce_review`` (and the
implementation incumbents) even when the model, effort, tasks, oracles,
incumbent skill bytes, and CE plugin have not changed. Those arms are the
baseline the gate compares a *new* candidate against — they are not the
thing being evolved. Replaying them burns two-thirds of a generation.
This module selects prior ``results.jsonl`` rows that are safe to carry
forward. Candidate arms are never reused. A mismatch on any bound field
falls through to a paid cell. Missing artifacts also fall through: a reused
row that the proposer cannot read is worse than spending the tokens again.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import stat
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path, PurePosixPath
from typing import Any
from .evolution import CANDIDATE_ARMS, EVIDENCE_MAX_AGE_DAYS
from .proposer_sandbox import SandboxError
from .runner_sessions import MAX_TRANSCRIPT_BYTES, PARENT_EVENT_STREAM_SOURCE
from .runtime_mounts import CE_ARMS
from .task_assets import COPY_CHUNK_BYTES, _write_all
REUSABLE_COMPARATOR_ARMS = frozenset(
{
"review",
"ce_review",
"workflow",
"workflow_direct",
"ce_workflow",
"ce_workflow_direct",
"baseline",
"baseline_nomcp",
}
)
# Must stay aligned with runner.EXCLUDED_ERROR_KINDS plus review-invalid.
# A reused row becomes promotion evidence; excluded kinds cannot enter that set.
REUSE_EXCLUDED_ERROR_KINDS = frozenset(
{
"session-error",
"infra-error",
"evidence-unverified",
"cleanup-failure",
"review-evidence-invalid",
"cancelled",
}
)
_TRANSCRIPT_NAME = re.compile(r"[A-Za-z0-9._-]{1,200}")
CellKey = tuple[str, str, int]
@dataclass(frozen=True)
class TaskReuseBinding:
"""Per-task identity the prior row must still match."""
task_base_sha: str
task_prompt_digest: str
oracle_digest: str
oracle_command_digest: str
oracle_manifest_digest: str
# The cell's environment is part of its identity: a comparator measured
# against different task assets or different sandbox dependencies is a
# measurement of a different machine, not a baseline for this sweep.
task_asset_manifest_digest: str | None = None
sandbox_dependency_manifest_digest: str | None = None
@dataclass(frozen=True)
class ComparatorReuseExpectation:
"""Sweep-wide lock for comparator reuse. Any drift pays for a fresh cell."""
model: str
effort: str
sandbox_backend: str
runtime_digest: str | None
now: datetime
max_age: timedelta
tasks: Mapping[str, TaskReuseBinding]
skill_digests: Mapping[str, str | None]
ce_plugin_version: str | None
ce_plugin_manifest_digest: str | None
def load_result_rows(path: Path) -> list[dict[str, Any]]:
"""Load ``results.jsonl``; skip malformed lines the same way evolve does."""
rows: list[dict[str, Any]] = []
for line in path.read_text().splitlines():
if not line.strip():
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(row, dict):
rows.append(row)
return rows
def current_runtime_digest() -> str | None:
"""Harness lockfile digest exported by ``run-evolution.sh``, if present."""
value = os.environ.get("RUNTIME_DIGEST", "").strip()
return value or None
def row_is_reusable_comparator(row: Mapping[str, Any], expected: ComparatorReuseExpectation) -> bool:
"""True when ``row`` is a complete, still-valid comparator measurement."""
arm = row.get("arm")
if not isinstance(arm, str) or arm in CANDIDATE_ARMS or arm not in REUSABLE_COMPARATOR_ARMS:
return False
if row.get("error_kind") in REUSE_EXCLUDED_ERROR_KINDS:
return False
if row.get("error_kind") not in (None, ""):
return False
if row.get("ok") is not True:
return False
if row.get("transcript_missing") is True:
return False
if row.get("candidate_overlay_digest") not in (None, ""):
return False
# Age against the ORIGINAL measurement, not the copy time: materialize_reused_row
# restamps recorded_at, so a chained row would otherwise refresh its own clock
# and never expire. Bound both directions - a future stamp is corrupt, not fresh.
recorded = _parse_recorded_at(row.get("reused_from_recorded_at") or row.get("recorded_at"))
if recorded is None:
return False
age = expected.now - recorded
if age > expected.max_age or age < timedelta(0):
return False
if row.get("model") != expected.model and row.get("benchmark_model") != expected.model:
return False
if row.get("effort") != expected.effort:
return False
if row.get("sandbox_backend") != expected.sandbox_backend:
return False
# Fail closed. A row with no runtime_digest was measured by a harness that
# did not record one, which is exactly the drift this lock exists to catch;
# treating the absence as agreement made every legacy row reusable forever.
prior_runtime = row.get("runtime_digest")
if not isinstance(prior_runtime, str) or not prior_runtime:
return False
if not expected.runtime_digest or prior_runtime != expected.runtime_digest:
return False
task_id = row.get("task")
binding = expected.tasks.get(task_id) if isinstance(task_id, str) else None
if binding is None:
return False
if row.get("task_base_sha") != binding.task_base_sha:
return False
if row.get("task_prompt_digest") != binding.task_prompt_digest:
return False
if row.get("oracle_digest") != binding.oracle_digest:
return False
if row.get("oracle_command_digest") != binding.oracle_command_digest:
return False
if row.get("oracle_manifest_digest") != binding.oracle_manifest_digest:
return False
# Fail closed on both sides, as the runtime digest does: an unbound
# expectation means this sweep could not determine its own environment, and
# a row without the field was measured before it was recorded.
for field, bound in (
("task_asset_manifest_digest", binding.task_asset_manifest_digest),
("sandbox_dependency_manifest_digest", binding.sandbox_dependency_manifest_digest),
):
prior = row.get(field)
if not isinstance(prior, str) or not prior or not bound or prior != bound:
return False
if arm in CE_ARMS:
if row.get("ce_plugin_version") != expected.ce_plugin_version:
return False
if row.get("ce_plugin_manifest_digest") != expected.ce_plugin_manifest_digest:
return False
else:
expected_skill = expected.skill_digests.get(arm)
if not expected_skill or row.get("skill_digest") != expected_skill:
return False
if arm in {"review", "ce_review"}:
if row.get("review_evidence_valid") is not True:
return False
# The artifact, not just the score derived from it. materialize_reused_row
# copies it only when the name is present, so without this a row whose
# artifact copy never happened could be carried forward as a scored
# review that a proposer then cannot read - evidence by assertion.
review_artifact = row.get("review_artifact")
if not isinstance(review_artifact, str) or not review_artifact:
return False
if not isinstance(row.get("review_score"), dict):
return False
if row.get("review_weighted_f1") is None:
return False
artifacts = row.get("transcript_artifacts")
if not isinstance(artifacts, list) or not artifacts:
return False
try:
for artifact in artifacts:
_transcript_metadata(artifact)
except SandboxError:
return False
return True
def select_reusable_comparator_rows(
rows: Sequence[Mapping[str, Any]],
*,
expected: ComparatorReuseExpectation,
) -> dict[CellKey, dict[str, Any]]:
"""Index reusable rows by ``(task, arm, run)``. Conflicting duplicates drop the key."""
chosen: dict[CellKey, dict[str, Any]] = {}
blocked: set[CellKey] = set()
for row in rows:
if not row_is_reusable_comparator(row, expected):
continue
task_id = row["task"]
arm = row["arm"]
run = row.get("run")
if not isinstance(run, int) or isinstance(run, bool) or run < 0:
continue
key = (str(task_id), str(arm), run)
if key in blocked:
continue
previous = chosen.get(key)
if previous is None:
chosen[key] = dict(row)
continue
if _row_identity(previous) != _row_identity(row):
blocked.add(key)
chosen.pop(key, None)
return chosen
def materialize_reused_row(
row: Mapping[str, Any],
*,
source_dir: Path,
dest_dir: Path,
) -> dict[str, Any]:
"""Copy digest-bound artifacts into this sweep's evidence dir and stamp reuse."""
source, _ = _resolved_directory(source_dir, label="reuse source")
dest, _ = _resolved_directory(dest_dir, label="reuse destination")
if source == dest:
raise SandboxError("comparator reuse cannot read and write the same results directory")
materialized = dict(row)
materialized["reused"] = True
# Keep the FIRST measurement time across a chain. Overwriting it with the
# previous copy's stamp let a row refresh its own clock every generation and
# outlive the max_age bound entirely.
materialized["reused_from_recorded_at"] = row.get("reused_from_recorded_at") or row.get("recorded_at")
materialized["recorded_at"] = datetime.now(UTC).isoformat()
artifacts = row.get("transcript_artifacts")
if not isinstance(artifacts, list) or not artifacts:
raise SandboxError("reused row is missing transcript_artifacts")
# Every path below is resolved against a held descriptor, never re-walked
# from a name. Both roots are already symlink-free (_resolved_directory
# resolved them), and pinning them here means the components under them
# cannot be swapped out from under a check that already passed.
with (
_open_pinned_root(source_dir, label="reuse source") as source_fd,
_open_pinned_root(dest_dir, label="reuse destination") as dest_fd,
):
copied_artifacts: list[dict[str, Any]] = []
for artifact in artifacts:
copied_artifacts.append(_copy_transcript_artifact(source_fd, dest_fd, artifact))
materialized["transcript_artifacts"] = copied_artifacts
review_name = row.get("review_artifact")
if isinstance(review_name, str) and review_name:
_copy_named_artifact(source_fd, dest_fd, review_name, label="review artifact")
task = row.get("task")
arm = row.get("arm")
run = row.get("run")
if isinstance(task, str) and isinstance(arm, str) and isinstance(run, int) and not isinstance(run, bool):
patch_name = f"{task}-{arm}-run{run}.patch"
if _is_regular_at(patch_name, dir_fd=source_fd):
_copy_named_artifact(source_fd, dest_fd, patch_name, label="patch artifact")
return materialized
def default_reuse_max_age() -> timedelta:
return timedelta(days=EVIDENCE_MAX_AGE_DAYS)
def _row_identity(row: Mapping[str, Any]) -> tuple[Any, ...]:
return (
row.get("skill_digest"),
row.get("oracle_digest"),
row.get("review_weighted_f1"),
row.get("ce_plugin_manifest_digest"),
row.get("recorded_at"),
)
def _parse_recorded_at(value: Any) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def _transcript_metadata(metadata: Any) -> tuple[str, str, int]:
if not isinstance(metadata, dict) or set(metadata) != {"path", "sha256", "bytes", "source"}:
raise SandboxError("transcript artifact metadata must contain only path, sha256, bytes, and source")
relative = metadata["path"]
digest = metadata["sha256"]
size = metadata["bytes"]
if metadata["source"] != PARENT_EVENT_STREAM_SOURCE:
raise SandboxError("transcript artifact source is not the parent event stream")
if not isinstance(relative, str) or not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
raise SandboxError("transcript artifact metadata is malformed")
if not isinstance(size, int) or isinstance(size, bool) or size < 0 or size > MAX_TRANSCRIPT_BYTES:
raise SandboxError("transcript artifact byte count is out of range")
relative_path = PurePosixPath(relative)
if (
relative_path.is_absolute()
or len(relative_path.parts) != 2
or relative_path.parts[0] != "transcripts"
or any(part in {"", ".", ".."} for part in relative_path.parts)
or _TRANSCRIPT_NAME.fullmatch(relative_path.parts[1]) is None
):
raise SandboxError(f"unsafe transcript artifact path: {relative!r}")
return relative, digest, size
def _resolved_directory(path: Path, *, label: str) -> tuple[Path, tuple[int, int]]:
"""An existing, non-symlink directory, resolved through its parents.
Deliberately weaker than proposer_sandbox's same-shaped helper, which
refuses every symlink hop in the path. That one guards a MOUNT ROOT, where
a hop changes what an untrusted session is handed. This one guards a DATA
directory whose contents are validated individually anyway - every file
read goes through ``_regular_file`` (lstat, symlinks rejected) and every
write through ``O_NOFOLLOW`` - so a symlinked parent grants nothing those
guards do not already cover, while refusing one would reject ordinary
setups such as a symlinked artifacts directory or macOS's /var.
Separately named because they make different promises. Do not merge them
without first deciding which promise the reuse path should make.
"""
resolved = path.expanduser()
try:
metadata = resolved.lstat()
except OSError as exc:
raise SandboxError(f"{label} is unavailable: {resolved}: {exc}") from exc
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
raise SandboxError(f"{label} must be a real directory: {resolved}")
return resolved.resolve(), (metadata.st_dev, metadata.st_ino)
@contextmanager
def _open_pinned_root(path: Path, *, label: str) -> Iterator[int]:
"""Open a checked root and prove it is still the directory that was checked.
The symlink POLICY above is deliberate and unchanged: parent hops stay
allowed, so a symlinked artifacts directory or macOS's /var still works.
What is closed here is separate from that policy - the gap between checking
a name and using it. lstat names one directory and resolve() re-walks the
same name afterwards, so a prior sweep that renames its results root and
drops a symlink in its place is resolved to somewhere else entirely, and
O_NOFOLLOW on the open cannot see a link that resolve() already followed.
Comparing the opened descriptor's identity to the checked one costs an
fstat and rejects nothing that holds still: a stable directory always
matches itself. It matters for reuse specifically because the failure is
silent - rows would be copied out of the wrong directory and folded into a
comparator baseline as though they were this sweep's own evidence.
"""
resolved, expected = _resolved_directory(path, label=label)
with _open_real_directory(resolved, label=label) as fd:
opened = os.fstat(fd)
if (opened.st_dev, opened.st_ino) != expected:
raise SandboxError(f"{label} was replaced between the check and the open: {resolved}")
yield fd
def _copy_transcript_artifact(source_fd: int, dest_fd: int, metadata: Mapping[str, Any]) -> dict[str, Any]:
relative, expected_digest, expected_size = _transcript_metadata(metadata)
name = PurePosixPath(relative).name
# Both `transcripts` components are opened as descriptors, not checked as
# names. An lstat that passes and a pathname that is used afterwards are two
# different directories whenever a concurrent writer renames the first one
# away — which the reuse directory, written by a prior sweep, invites.
with (
_open_real_directory("transcripts", dir_fd=dest_fd, label="transcript destination", create=True) as dest_dir_fd,
_open_real_directory("transcripts", dir_fd=source_fd, label="transcript source") as source_dir_fd,
):
os.fchmod(dest_dir_fd, 0o700)
# One descriptor for the whole transfer, and ONE read of it. Hashing the
# source and then reading it again to copy leaves the recorded digest
# describing bytes that are not the bytes written: the descriptor stops
# the pathname being substituted, not the inode being rewritten, and
# this directory belongs to a sweep that may still be writing. Digest
# what is copied, then judge it.
with _open_regular(name, dir_fd=source_dir_fd, label="transcript") as artifact_fd:
digest, copied_bytes = _copy_owner_only(
artifact_fd, name, dir_fd=dest_dir_fd, max_bytes=expected_size
)
if copied_bytes != expected_size or digest != expected_digest:
# The destination now holds bytes no expectation vouches for.
os.unlink(name, dir_fd=dest_dir_fd)
drift = "size" if copied_bytes != expected_size else "digest"
raise SandboxError(f"reused transcript {drift} drifted: {relative}")
return {"path": relative, "sha256": digest, "bytes": expected_size, "source": PARENT_EVENT_STREAM_SOURCE}
def _copy_named_artifact(source_fd: int, dest_fd: int, name: str, *, label: str) -> None:
relative = PurePosixPath(name)
if relative.is_absolute() or len(relative.parts) != 1 or relative.parts[0] in {"", ".", ".."}:
raise SandboxError(f"unsafe {label} path: {name!r}")
with _open_regular(name, dir_fd=source_fd, label=label) as artifact_fd:
# No expectation is recorded for these, so the digest is discarded - but
# "no recorded size" is not "no limit". The source is a prior sweep
# directory that can change between sweeps, so a replaced artifact could
# be arbitrarily large; MAX_TRANSCRIPT_BYTES is the ceiling the capture
# path already enforces on evidence of this kind.
_, copied = _copy_owner_only(artifact_fd, name, dir_fd=dest_fd, max_bytes=MAX_TRANSCRIPT_BYTES)
if copied > MAX_TRANSCRIPT_BYTES:
os.unlink(name, dir_fd=dest_fd)
raise SandboxError(f"reused {label} exceeds {MAX_TRANSCRIPT_BYTES} bytes: {name}")
def _require_openat() -> None:
"""openat is what makes a checked directory and a used directory the same one.
Without it the only alternative is to re-walk the name after the check,
which is exactly the race this module is guarding. Refusing is safe: the
caller in runner treats a SandboxError from reuse as "run a paid cell", so
a platform without openat pays for the cells rather than copying through a
directory nobody verified. The sweep itself is Linux-only anyway (bwrap,
/proc/uptime); this is about the unit tests and about failing loudly.
"""
if os.open not in os.supports_dir_fd or os.lstat not in os.supports_dir_fd:
raise SandboxError("comparator reuse requires POSIX openat support (os.supports_dir_fd)")
def _is_regular_at(name: str, *, dir_fd: int) -> bool:
"""True when `name` under the pinned directory is a regular non-symlink file."""
try:
metadata = os.lstat(name, dir_fd=dir_fd)
except OSError:
return False
return stat.S_ISREG(metadata.st_mode)
@contextmanager
def _open_real_directory(
path: Path | str,
*,
dir_fd: int | None = None,
label: str,
create: bool = False,
) -> Iterator[int]:
"""Open one directory that is not a symlink, and hold it for every use below.
``O_DIRECTORY | O_NOFOLLOW`` makes the check and the open a single syscall,
so unlike an ``lstat`` followed by a path, there is no window in which the
directory can be replaced. ``_resolved_directory`` still tolerates a
symlinked reuse ROOT — it hands this function the already-resolved path —
but every component below it is pinned.
"""
_require_openat()
if create:
try:
os.mkdir(path, 0o700, dir_fd=dir_fd)
except FileExistsError:
# Already there is the ordinary case — a second artifact from the
# same row. What it already IS still has to be proven, and the
# O_DIRECTORY|O_NOFOLLOW open below is what proves it, so there is
# nothing to do here.
pass
except OSError as exc:
raise SandboxError(f"{label} cannot be created: {path}: {exc}") from exc
try:
descriptor = os.open(
path,
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0),
dir_fd=dir_fd,
)
except FileNotFoundError as exc:
# Absent is a different fact from present-but-not-a-real-directory, and
# the caller falls through to a paid cell on either.
raise SandboxError(f"{label} is missing: {path}") from exc
except OSError as exc:
raise SandboxError(f"{label} must be a real directory: {path}: {exc}") from exc
try:
# O_DIRECTORY is the check on Linux; the fstat covers a platform whose
# os module does not define it, where the flag degrades to 0.
if not stat.S_ISDIR(os.fstat(descriptor).st_mode):
raise SandboxError(f"{label} must be a real directory: {path}")
yield descriptor
finally:
os.close(descriptor)
@contextmanager
def _open_regular(name: str, *, dir_fd: int, label: str) -> Iterator[int]:
"""Open a regular non-symlink file under a pinned directory, and hold it.
Checking a name and then re-opening it is a race the reuse directory is
exposed to: it is written by a previous sweep and read by this one, so a
concurrent writer can replace a validated file with a symlink in between.
Resolving against ``dir_fd`` removes the directory half, ``O_NOFOLLOW``
refuses the leaf link, and the fstat comparison proves the open descriptor
is the inode that was checked — the same guarantee
evolution._bounded_regular_bytes makes for evidence files.
"""
_require_openat()
try:
before = os.lstat(name, dir_fd=dir_fd)
except OSError as exc:
raise SandboxError(f"{label} is missing: {name}: {exc}") from exc
if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode):
raise SandboxError(f"{label} must be a regular non-symlink file: {name}")
try:
descriptor = os.open(name, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=dir_fd)
except OSError as exc:
raise SandboxError(f"{label} is unreadable: {name}: {exc}") from exc
try:
opened = os.fstat(descriptor)
if not stat.S_ISREG(opened.st_mode) or (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
raise SandboxError(f"{label} changed while opening: {name}")
yield descriptor
finally:
os.close(descriptor)
def _copy_owner_only(source: int, name: str, *, dir_fd: int, max_bytes: int | None = None) -> tuple[str, int]:
"""Copy one open file into the pinned directory; return what was written.
The digest is taken from the same buffers that are written, so it describes
the copy rather than a state the source was in at some earlier read.
``max_bytes`` bounds the copy itself. The source is a prior sweep directory
this module already treats as concurrently writable, so a transcript
appended to after its metadata was recorded would otherwise be streamed to
EOF and only then compared against its declared size - filling the
destination, or never reaching EOF at all, long before the drift check could
reject it. Stopping one byte past the ceiling keeps that comparison
meaningful while bounding the work.
"""
# O_CREAT|O_EXCL is the existence check, and unlike a stat beforehand it is
# atomic: a file appearing between check and open cannot slip through.
try:
descriptor = os.open(
name,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
0o600,
dir_fd=dir_fd,
)
except FileExistsError as exc:
raise SandboxError(f"reuse destination already exists: {name}") from exc
try:
os.fchmod(descriptor, 0o600)
os.lseek(source, 0, os.SEEK_SET)
digest = hashlib.sha256()
written = 0
limit = None if max_bytes is None else max_bytes + 1
while True:
want = COPY_CHUNK_BYTES if limit is None else min(COPY_CHUNK_BYTES, limit - written)
if want <= 0:
break
chunk = os.read(source, want)
if not chunk:
break
digest.update(chunk)
written += len(chunk)
_write_all(descriptor, chunk)
os.fsync(descriptor)
return digest.hexdigest(), written
finally:
os.close(descriptor)

View file

@ -7,7 +7,6 @@ import os
import secrets import secrets
import stat import stat
import statistics import statistics
from collections.abc import Sequence
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any from typing import Any
@ -22,94 +21,9 @@ from .proposer_sandbox import (
CANDIDATE_ARMS = { CANDIDATE_ARMS = {
"candidate_workflow": "workflow", "candidate_workflow": "workflow",
"candidate_workflow_direct": "workflow_direct", "candidate_workflow_direct": "workflow_direct",
"candidate_review": "review",
} }
PROMOTION_SCHEMA_VERSION = 6
def promotion_policy(
candidate_arms: Sequence[str],
*,
metric: str = "cost_usd",
min_runs: int = 3,
min_improvement_pct: float = 5.0,
max_task_regression_pct: float = 20.0,
) -> dict[str, dict[str, Any]]:
"""The exact per-arm policy shared by evidence production and application."""
if not candidate_arms or len(set(candidate_arms)) != len(candidate_arms):
raise ValueError("promotion policy requires unique candidate arms")
policies = {}
for arm in candidate_arms:
if arm not in CANDIDATE_ARMS:
raise ValueError(f"unsupported candidate arm: {arm}")
policies[arm] = (
{
"metric": "review_weighted_f1",
"min_runs": min_runs,
"min_improvement": 0.01,
"quality_rule": "correct verdict on every repeat; minimum blocker recall; no clean-control regression",
}
if arm == "candidate_review"
else {
"metric": metric,
"min_runs": min_runs,
"min_improvement_pct": min_improvement_pct,
"max_task_regression_pct": max_task_regression_pct,
"max_failed_task_regression_pct": MAX_FAILED_TASK_REGRESSION_PCT,
"min_gated_task_ratio": MIN_GATED_TASK_RATIO,
"quality_rule": "no per-task resolution-rate regression",
}
)
return policies
def promotion_evidence(
results: dict[str, dict[str, dict[str, Any]]],
*,
policy: dict[str, dict[str, Any]],
model: str | None,
complete: bool,
) -> dict[str, Any]:
"""Produce discriminated, recomputable decisions, including partial reports."""
decisions = []
for candidate, rules in policy.items():
common = {
"incumbent_arm": CANDIDATE_ARMS[candidate],
"candidate_arm": candidate,
"model": model,
"min_runs": rules["min_runs"],
}
if candidate == "candidate_review":
decision = evaluate_review_candidate(results, **common, min_improvement=rules["min_improvement"])
else:
decision = evaluate_candidate(
results,
**common,
**{
key: rules[key]
for key in (
"metric",
"min_improvement_pct",
"max_task_regression_pct",
"max_failed_task_regression_pct",
)
},
)
if not complete:
decision["decision"] = "insufficient_evidence"
decision["reasons"].append("sweep aborted; partial evidence cannot promote")
decisions.append(decision)
return {
"schema_version": PROMOTION_SCHEMA_VERSION,
"run_status": "complete" if complete else "aborted",
"policy": policy,
"decisions": decisions,
}
CANDIDATE_SKILLS = { CANDIDATE_SKILLS = {
"gitnexus-plan", "gitnexus-plan",
"gitnexus-review",
"gitnexus-work", "gitnexus-work",
} }
# Skills each incumbent arm actually loads in its sessions. An overlay that # Skills each incumbent arm actually loads in its sessions. An overlay that
@ -118,7 +32,6 @@ CANDIDATE_SKILLS = {
ARM_SKILLS = { ARM_SKILLS = {
"workflow": ("gitnexus-plan", "gitnexus-work"), "workflow": ("gitnexus-plan", "gitnexus-work"),
"workflow_direct": ("gitnexus-work",), "workflow_direct": ("gitnexus-work",),
"review": ("gitnexus-review",),
} }
# Repo-local prompts whose bytes are evidence for each executed arm. Keep this # Repo-local prompts whose bytes are evidence for each executed arm. Keep this
# distinct from ``ARM_SKILLS``: that mapping defines which skills a promotable # distinct from ``ARM_SKILLS``: that mapping defines which skills a promotable
@ -141,19 +54,6 @@ MAIN_LOOP_ONLY_WARNING = (
"each run output, deduplicating events " "each run output, deduplicating events "
"that share one message.id." "that share one message.id."
) )
# Failure kinds the prompts under test cause, not the task: the skill never
# ran at all. Both arms failing a task this way is evidence about the skills,
# so such a task stays inside the gate however unresolvable it looks.
SKILL_ATTRIBUTABLE_ERROR_KINDS = frozenset({"skill-not-invoked"})
# Leaving the quality gate is not leaving the spend gate. A candidate may fail
# the same oracle the incumbent fails, but not at a multiple of its cost — an
# ungated task is still real money and still ranks on the metric.
MAX_FAILED_TASK_REGRESSION_PCT = 100.0
# Promotion must rest on a real evidence base. Half the paired tasks is the
# loosest rule the three-task production set can carry: it tolerates the one
# scenario neither arm resolves and refuses a generation that has quietly
# decayed to a single gated task deciding everything.
MIN_GATED_TASK_RATIO = 0.5
EVIDENCE_MAX_AGE_DAYS = 90 EVIDENCE_MAX_AGE_DAYS = 90
MAX_CANDIDATE_OVERLAY_BYTES = 4 * 1024 * 1024 MAX_CANDIDATE_OVERLAY_BYTES = 4 * 1024 * 1024
MAX_SKILL_FINGERPRINT_BYTES = 4 * 1024 * 1024 MAX_SKILL_FINGERPRINT_BYTES = 4 * 1024 * 1024
@ -425,7 +325,7 @@ def candidate_overlay_files(overlay: Path) -> list[Path]:
): ):
raise ValueError( raise ValueError(
"candidate overlays may only contain Markdown files under " "candidate overlays may only contain Markdown files under "
".claude/skills/gitnexus-{plan,review,work}: " ".claude/skills/gitnexus-{plan,work}: "
f"{relative}" f"{relative}"
) )
return entries return entries
@ -445,8 +345,6 @@ def required_candidate_arms(overlay: Path) -> list[str]:
required.append("candidate_workflow") required.append("candidate_workflow")
if "gitnexus-work" in touched: if "gitnexus-work" in touched:
required.append("candidate_workflow_direct") required.append("candidate_workflow_direct")
if "gitnexus-review" in touched:
required.append("candidate_review")
return required return required
@ -467,69 +365,6 @@ def candidate_overlay_digest(overlay: Path) -> str:
return digest return digest
def _commit_sandbox_paths(
sandbox: SandboxSession,
relative_paths: Sequence[str],
*,
message: str,
require_change: bool,
) -> bool:
"""Stage and commit paths inside the outer sandbox.
Returns True when a commit was created. ``require_change`` keeps the
candidate-overlay contract: a no-op overlay is an error, while an
incumbent skill seed may already match the historical tree.
"""
mkdir_command = ["/bin/mkdir", "-p", f"{SANDBOX_TMP}/wfbench-empty-hooks"]
mkdir_result = sandbox.run(
mkdir_command,
timeout=60,
env=build_sandbox_environment(),
)
if not mkdir_result.ok:
raise ManagedProcessError(mkdir_command, mkdir_result)
if not relative_paths:
if require_change:
raise ValueError("candidate overlay is byte-identical to the incumbent skills")
return False
# Historical review SHAs gitignore `.claude/skills/*` and lack the current
# per-skill allowlist. Force-add so a seed or overlay of harness-owned
# skill bytes is not rejected as an ignored path.
command, added = _sandbox_overlay_git(sandbox, ["add", "-f", "--", *relative_paths])
if not added.ok:
raise ManagedProcessError(command, added)
command, changed = _sandbox_overlay_git(
sandbox,
["diff", "--cached", "--quiet", "--no-ext-diff", "--no-textconv", "--"],
)
if changed.returncode == 0:
if require_change:
raise ValueError("candidate overlay is byte-identical to the incumbent skills")
return False
if changed.returncode != 1:
raise ManagedProcessError(command, changed)
command, committed = _sandbox_overlay_git(
sandbox,
[
"commit",
"--quiet",
"--no-verify",
"-m",
message,
],
extra_config=(
"user.name=workflow-bench",
"user.email=workflow-bench@invalid",
),
)
if not committed.ok:
raise ManagedProcessError(command, committed)
return True
def apply_candidate_overlay( def apply_candidate_overlay(
overlay: Path, overlay: Path,
worktree: Path, worktree: Path,
@ -548,96 +383,47 @@ def apply_candidate_overlay(
for relative, content in payload: for relative, content in payload:
_replace_regular_file(worktree, relative, content) _replace_regular_file(worktree, relative, content)
relative_paths.append(relative.as_posix()) relative_paths.append(relative.as_posix())
_commit_sandbox_paths(
sandbox, mkdir_command = ["/bin/mkdir", "-p", f"{SANDBOX_TMP}/wfbench-empty-hooks"]
relative_paths, mkdir_result = sandbox.run(
message="benchmark candidate skill overlay", mkdir_command,
require_change=True, timeout=60,
env=build_sandbox_environment(),
) )
if not mkdir_result.ok:
raise ManagedProcessError(mkdir_command, mkdir_result)
command, added = _sandbox_overlay_git(sandbox, ["add", "--", *relative_paths])
if not added.ok:
raise ManagedProcessError(command, added)
command, changed = _sandbox_overlay_git(
sandbox,
["diff", "--cached", "--quiet", "--no-ext-diff", "--no-textconv", "--"],
)
if changed.returncode == 0:
raise ValueError("candidate overlay is byte-identical to the incumbent skills")
if changed.returncode != 1:
raise ManagedProcessError(command, changed)
command, committed = _sandbox_overlay_git(
sandbox,
[
"commit",
"--quiet",
"--no-verify",
"-m",
"benchmark candidate skill overlay",
],
extra_config=(
"user.name=workflow-bench",
"user.email=workflow-bench@invalid",
),
)
if not committed.ok:
raise ManagedProcessError(command, committed)
return digest return digest
def seed_evaluated_skills(
source_repo: Path,
worktree: Path,
*,
sandbox: SandboxSession,
arm: str,
) -> None:
"""Install the current evaluated skill tree into a historical clone.
Review evolution scores the current (or overlay) ``gitnexus-review`` skill
against a historical PR checkout. Older SHAs predate that skill, and
using whatever prose happened to exist at the reviewed commit would make
the incumbent arm a moving target. Copy the harness checkout's skill
bytes and commit them before setup so ``git status`` still shows only
the task patch.
"""
skill_names = EVALUATED_ARM_SKILLS.get(arm)
if not skill_names:
return
source_repo = source_repo.expanduser().absolute()
expected_clone = Path(os.path.abspath(worktree.expanduser()))
sandbox_clone = Path(os.path.abspath(sandbox.clone.expanduser()))
if sandbox_clone != expected_clone:
raise ValueError("skill seed sandbox does not bind the requested clone")
_require_real_directory(source_repo, label="incumbent skill repository")
if source_repo.resolve(strict=True) != source_repo:
raise ValueError(f"incumbent skill repository cannot traverse symlinks: {source_repo}")
relative_paths: list[str] = []
total = 0
for skill_name in skill_names:
_require_directory_chain(
source_repo,
Path(".claude") / "skills" / skill_name,
label="incumbent skill root",
)
skill_root = source_repo / ".claude" / "skills" / skill_name
pending = [skill_root]
while pending:
directory = pending.pop()
try:
children = list(os.scandir(directory))
except OSError as exc:
raise ValueError(f"incumbent skill directory is unreadable: {directory}: {exc}") from exc
for item in children:
path = Path(item.path)
if item.is_symlink():
raise ValueError(
"incumbent skill seed cannot contain symlinks: "
f"{path.relative_to(source_repo)}"
)
if item.is_dir(follow_symlinks=False):
pending.append(path)
continue
if not item.is_file(follow_symlinks=False):
raise ValueError(
"incumbent skill seed entries must be regular files: "
f"{path.relative_to(source_repo)}"
)
total += item.stat(follow_symlinks=False).st_size
if total > MAX_SKILL_FINGERPRINT_BYTES:
raise ValueError("incumbent skill seed exceeds the bounded evidence limit")
relative = Path(".claude") / "skills" / skill_name / path.relative_to(skill_root)
content = _bounded_regular_bytes(
path,
limit=MAX_SKILL_FINGERPRINT_BYTES,
label="incumbent skill file",
)
_replace_regular_file(worktree, relative, content)
relative_paths.append(PurePosixPath(relative.as_posix()).as_posix())
_commit_sandbox_paths(
sandbox,
relative_paths,
message="benchmark incumbent review skill",
require_change=False,
)
def unexercised_overlay_skills(overlay: Path, candidate_arms: list[str]) -> list[str]: def unexercised_overlay_skills(overlay: Path, candidate_arms: list[str]) -> list[str]:
"""Overlay skills that no selected candidate arm would ever load. """Overlay skills that no selected candidate arm would ever load.
@ -689,133 +475,6 @@ def skill_fingerprint(worktree: Path, arm: str) -> str | None:
return fingerprint_files(worktree, files) return fingerprint_files(worktree, files)
def evaluate_review_candidate(
results: dict[str, dict[str, dict[str, Any]]],
*,
incumbent_arm: str,
candidate_arm: str,
model: str | None,
min_runs: int = 3,
min_improvement: float = 0.01,
) -> dict[str, Any]:
"""Quality-first promotion gate for paired read-only review arms."""
reasons: list[str] = []
task_rows: list[dict[str, Any]] = []
insufficient = not model
regression = False
improvement = False
if not model:
reasons.append("a named --model is required so review evidence cannot drift")
for task_id, arms in sorted(results.items()):
if incumbent_arm not in arms or candidate_arm not in arms:
insufficient = True
reasons.append(f"{task_id}: both {incumbent_arm} and {candidate_arm} are required")
continue
incumbent = arms[incumbent_arm]
candidate = arms[candidate_arm]
incumbent_runs = int(incumbent.get("valid_runs", 0))
candidate_runs = int(candidate.get("valid_runs", 0))
incumbent_score = incumbent.get("review_weighted_f1")
candidate_score = candidate.get("review_weighted_f1")
incumbent_blockers = incumbent.get("review_blocker_recall")
candidate_blockers = candidate.get("review_blocker_recall")
incumbent_fp = incumbent.get("review_false_positives")
candidate_fp = candidate.get("review_false_positives")
clean = bool(incumbent.get("review_clean_control", candidate.get("review_clean_control", False)))
incumbent_clean_pass = incumbent.get("review_clean_pass")
candidate_clean_pass = candidate.get("review_clean_pass")
task_rows.append(
{
"task": task_id,
"class": incumbent.get("class", ""),
"incumbent_weighted_f1": incumbent_score,
"incumbent": dict(incumbent),
"candidate": dict(candidate),
"gated": True,
"candidate_weighted_f1": candidate_score,
"incumbent_blocker_recall": incumbent_blockers,
"candidate_blocker_recall": candidate_blockers,
"incumbent_false_positives": incumbent_fp,
"candidate_false_positives": candidate_fp,
"clean_control": clean,
"incumbent_clean_pass": incumbent_clean_pass,
"candidate_clean_pass": candidate_clean_pass,
}
)
if (
incumbent_runs < min_runs
or candidate_runs < min_runs
or incumbent_runs != candidate_runs
or incumbent.get("excluded_runs")
or candidate.get("excluded_runs")
):
insufficient = True
reasons.append(
f"{task_id}: needs {min_runs} valid paired runs with zero exclusions "
f"(got {incumbent_runs}/{candidate_runs})"
)
required_values = (
(incumbent_fp, candidate_fp, incumbent_clean_pass, candidate_clean_pass)
if clean
else (incumbent_score, candidate_score, incumbent_fp, candidate_fp)
)
if any(value is None for value in required_values):
insufficient = True
reasons.append(f"{task_id}: structured review quality metrics are incomplete")
continue
if candidate.get("review_verdict_correct") is None:
insufficient = True
reasons.append(f"{task_id}: candidate verdict evidence is incomplete")
elif candidate["review_verdict_correct"] is not True:
regression = True
reasons.append(f"{task_id}: candidate verdict was incorrect on a valid repeat")
if (incumbent_blockers is None) != (candidate_blockers is None):
insufficient = True
reasons.append(f"{task_id}: blocker recall evidence is incomplete")
if incumbent_blockers is not None and candidate_blockers is not None and float(candidate_blockers) < float(
incumbent_blockers
):
regression = True
reasons.append(f"{task_id}: blocker recall regressed")
if clean and float(candidate_fp) > float(incumbent_fp):
regression = True
reasons.append(f"{task_id}: false positives increased on a clean control")
if clean and bool(incumbent_clean_pass) and not bool(candidate_clean_pass):
regression = True
reasons.append(f"{task_id}: clean-control verdict regressed")
if not clean and float(candidate_score) + 1e-9 < float(incumbent_score):
regression = True
reasons.append(f"{task_id}: weighted review score regressed")
if not clean and float(candidate_score) >= float(incumbent_score) + min_improvement:
improvement = True
if not task_rows:
insufficient = True
reasons.append("no paired review task results were found")
if insufficient:
decision = "insufficient_evidence"
elif regression:
decision = "keep_incumbent"
elif not improvement:
decision = "keep_incumbent"
reasons.append("candidate did not improve weighted review quality on any corpus case")
else:
decision = "promote"
reasons.append("candidate improved weighted review quality without blocker or clean-control regression")
return {
"candidate_arm": candidate_arm,
"incumbent_arm": incumbent_arm,
"decision": decision,
"metric": "review_weighted_f1",
"model": model,
"tasks": task_rows,
"ungated_tasks": [],
"reasons": reasons,
}
def evaluate_candidate( def evaluate_candidate(
results: dict[str, dict[str, dict[str, Any]]], results: dict[str, dict[str, dict[str, Any]]],
*, *,
@ -826,18 +485,12 @@ def evaluate_candidate(
min_runs: int = 3, min_runs: int = 3,
min_improvement_pct: float = 5.0, min_improvement_pct: float = 5.0,
max_task_regression_pct: float = 20.0, max_task_regression_pct: float = 20.0,
max_failed_task_regression_pct: float = MAX_FAILED_TASK_REGRESSION_PCT,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Deterministically decide whether a prompt candidate is promotable. """Deterministically decide whether a prompt candidate is promotable.
Resolution is lexicographically primary: a cheaper candidate that fails Resolution is lexicographically primary: a cheaper candidate that fails
more tasks never wins. With equal quality, the candidate must clear the more tasks never wins. With equal quality, the candidate must clear the
configured median efficiency gain without a large per-task regression. configured median efficiency gain without a large per-task regression.
A task neither arm can resolve leaves the quality gate, but only on
evidence: a comparable metric, no skill-not-invoked run, and enough tasks
left inside the gate to decide anything. It still ranks against the
failed-task spend cap.
""" """
if metric not in PROMOTION_METRICS: if metric not in PROMOTION_METRICS:
raise ValueError(f"unsupported promotion metric: {metric}") raise ValueError(f"unsupported promotion metric: {metric}")
@ -883,66 +536,20 @@ def evaluate_candidate(
if (not metric_unavailable and incumbent_metric) if (not metric_unavailable and incumbent_metric)
else None else None
) )
# A task that both arms measured cleanly and neither ever resolved sits
# outside both arms' current capability. It carries no quality signal
# about the candidate, and its metric compares who spent more while
# failing the same oracle — so gating on it measures the task, not the
# candidate, and one such task vetoes every future promotion for as
# long as it stays in the set. Keep it in the evidence, out of the gate,
# and name it as task health instead.
#
# Ungating is itself a claim, so it needs evidence: the failures must
# be the task's (not a skill that never loaded) and the metric must be
# comparable, otherwise the task stays gated and the checks below name
# what is missing.
fully_measured = (
incumbent_runs >= min_runs
and candidate_runs >= min_runs
and incumbent_runs == candidate_runs
and not incumbent_excluded
and not candidate_excluded
)
skill_attributable = bool(
(set(incumbent.get("error_kinds", {})) | set(candidate.get("error_kinds", {})))
& SKILL_ATTRIBUTABLE_ERROR_KINDS
)
mutually_unresolved = fully_measured and not incumbent["resolved"] and not candidate["resolved"]
gated = not (mutually_unresolved and not skill_attributable and improvement is not None)
# The floor asks the candidate to be reliable where the incumbent is.
# On a task the incumbent never resolves there is no reliability to
# match, and holding partial candidate progress to it punished a
# candidate for resolving 1 of 3 runs while excusing it for resolving
# none — the strictly worse result. A skill that never loaded is the
# exception: those failures belong to the prompts, so the floor applies
# even with nothing on the incumbent's side to match.
quality_floor_enforced = bool(incumbent["resolved"]) or skill_attributable
task_rows.append( task_rows.append(
{ {
"task": task_id, "task": task_id,
"class": incumbent.get("class", ""), "class": incumbent.get("class", ""),
"incumbent_resolved": f"{incumbent['resolved']}/{incumbent_runs}", "incumbent_resolved": f"{incumbent['resolved']}/{incumbent_runs}",
"incumbent": dict(incumbent),
"candidate": dict(candidate),
"candidate_resolved": f"{candidate['resolved']}/{candidate_runs}", "candidate_resolved": f"{candidate['resolved']}/{candidate_runs}",
"incumbent_excluded_runs": incumbent_excluded, "incumbent_excluded_runs": incumbent_excluded,
"candidate_excluded_runs": candidate_excluded, "candidate_excluded_runs": candidate_excluded,
"candidate_quality_floor_met": candidate_runs > 0 and candidate["resolved"] == candidate_runs, "candidate_quality_floor_met": candidate_runs > 0 and candidate["resolved"] == candidate_runs,
"quality_floor_enforced": quality_floor_enforced,
"incumbent_metric": incumbent_metric, "incumbent_metric": incumbent_metric,
"candidate_metric": candidate_metric, "candidate_metric": candidate_metric,
"improvement_pct": improvement, "improvement_pct": improvement,
"gated": gated,
"skill_attributable_failure": skill_attributable,
} }
) )
if not gated:
if improvement < -max_failed_task_regression_pct:
efficiency_regression = True
reasons.append(
f"{task_id}: {metric} regressed {-improvement:.1f}% on a task neither arm resolved, "
f"above the {max_failed_task_regression_pct:.1f}% failed-task cap"
)
continue
if incumbent_runs < min_runs or candidate_runs < min_runs: if incumbent_runs < min_runs or candidate_runs < min_runs:
insufficient = True insufficient = True
@ -964,16 +571,11 @@ def evaluate_candidate(
if candidate_rate < incumbent_rate: if candidate_rate < incumbent_rate:
quality_regression = True quality_regression = True
reasons.append(f"{task_id}: resolution regressed from {incumbent_rate:.0%} to {candidate_rate:.0%}") reasons.append(f"{task_id}: resolution regressed from {incumbent_rate:.0%} to {candidate_rate:.0%}")
if quality_floor_enforced and candidate_runs > 0 and candidate["resolved"] != candidate_runs: if candidate_runs > 0 and candidate["resolved"] != candidate_runs:
quality_floor_failed = True quality_floor_failed = True
floor_trigger = (
"a run never invoked the skill under test"
if skill_attributable
else f"the incumbent resolves {incumbent['resolved']}/{incumbent_runs}"
)
reasons.append( reasons.append(
f"{task_id}: candidate must resolve every valid run for the oracle-backed quality floor " f"{task_id}: candidate must resolve every valid run for the oracle-backed quality floor "
f"({floor_trigger}; got {candidate['resolved']}/{candidate_runs})" f"(got {candidate['resolved']}/{candidate_runs})"
) )
if metric_unavailable: if metric_unavailable:
insufficient = True insufficient = True
@ -990,36 +592,11 @@ def evaluate_candidate(
f"{task_id}: {metric} regressed {-improvement:.1f}%, above the {max_task_regression_pct:.1f}% task cap" f"{task_id}: {metric} regressed {-improvement:.1f}%, above the {max_task_regression_pct:.1f}% task cap"
) )
ungated_tasks = [row["task"] for row in task_rows if not row["gated"]]
gated_tasks = [row["task"] for row in task_rows if row["gated"]]
if ungated_tasks:
# One line, not one per task: `reasons` is truncated to three entries
# when it is fed back to the proposer (evolve.summarize_gate), and a
# growing set of unsolvable tasks must not crowd out the reason the
# candidate actually won or lost. The full list ships structurally.
reasons.append(
f"not gated on {len(ungated_tasks)} task(s) neither arm resolved: {', '.join(ungated_tasks)} "
f"(evidence base: {len(gated_tasks)}/{len(task_rows)} paired tasks gated)"
)
if not task_rows: if not task_rows:
insufficient = True insufficient = True
reasons.append("no paired task results were found") reasons.append("no paired task results were found")
elif not gated_tasks:
# Every paired task was ungated, so nothing in this generation says
# anything about candidate quality. Refuse rather than fall through to
# an efficiency-only verdict on runs that all failed their oracle.
insufficient = True
reasons.append("no task supplied quality signal: neither arm resolved a run anywhere in the set")
elif len(gated_tasks) < MIN_GATED_TASK_RATIO * len(task_rows):
# Ungating one unsolvable task keeps promotion reachable; ungating most
# of the set turns "promote" into a verdict from whatever is left.
insufficient = True
reasons.append(
f"promotion evidence base is too thin: {len(gated_tasks)}/{len(task_rows)} paired tasks are gated "
f"(at least {MIN_GATED_TASK_RATIO:.0%} required)"
)
improvements = [row["improvement_pct"] for row in task_rows if row["gated"] and row["improvement_pct"] is not None] improvements = [row["improvement_pct"] for row in task_rows if row["improvement_pct"] is not None]
median_improvement = round(statistics.median(improvements), 1) if improvements else None median_improvement = round(statistics.median(improvements), 1) if improvements else None
incumbent_resolved = sum( incumbent_resolved = sum(
arms[incumbent_arm]["resolved"] for arms in results.values() if incumbent_arm in arms and candidate_arm in arms arms[incumbent_arm]["resolved"] for arms in results.values() if incumbent_arm in arms and candidate_arm in arms
@ -1063,8 +640,6 @@ def evaluate_candidate(
"metric": metric, "metric": metric,
"metric_warning": (MAIN_LOOP_ONLY_WARNING if metric in MAIN_LOOP_ONLY_METRICS else None), "metric_warning": (MAIN_LOOP_ONLY_WARNING if metric in MAIN_LOOP_ONLY_METRICS else None),
"median_improvement_pct": median_improvement, "median_improvement_pct": median_improvement,
"ungated_tasks": ungated_tasks,
"gated_tasks": gated_tasks,
"reasons": reasons, "reasons": reasons,
"tasks": task_rows, "tasks": task_rows,
} }

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@
# #
# uv run python -m workflow_bench.runner \ # uv run python -m workflow_bench.runner \
# --tasks workflow_bench/tasks.scenarios.yaml \ # --tasks workflow_bench/tasks.scenarios.yaml \
# --base-url http://localhost:4000 --anthropic-api-key "$LITELLM_MASTER_KEY" \ # --base-url http://localhost:4000 --auth-token "$LITELLM_MASTER_KEY" \
# --model free-coder # --model free-coder
# #
# Keep the proxy on loopback (litellm's default host). Anyone who can reach # Keep the proxy on loopback (litellm's default host). Anyone who can reach
@ -39,5 +39,5 @@ model_list:
general_settings: general_settings:
# No static default — export LITELLM_MASTER_KEY before starting the proxy # No static default — export LITELLM_MASTER_KEY before starting the proxy
# and pass the same value as --anthropic-api-key (see header). # and pass the same value as --auth-token (see header).
master_key: os.environ/LITELLM_MASTER_KEY master_key: os.environ/LITELLM_MASTER_KEY

View file

@ -1,45 +0,0 @@
"""Private gateway owner. EOF on stdin means the harness no longer exists.
Executed by absolute script path so the isolated gateway environment needs no
PYTHONPATH. The gateway receives DEVNULL, never the owner-liveness descriptor.
"""
from __future__ import annotations
import os
import signal
import sys
import threading
from process_control import run_managed
def main() -> int:
cancelled = threading.Event()
def watch_owner() -> None:
try:
# A buffered stdin lock held by a daemon aborts CPython shutdown
# when the proxy exits while the owner is still alive.
os.read(sys.stdin.fileno(), 1)
finally:
cancelled.set()
threading.Thread(target=watch_owner, daemon=True).start()
for signum in (signal.SIGTERM, signal.SIGINT):
signal.signal(signum, lambda *_: cancelled.set())
result = run_managed(
sys.argv[1:],
timeout=7 * 24 * 60 * 60,
cancel_event=cancelled,
echo_stdout=True,
)
if result.stderr_tail:
print(result.stderr_tail, file=sys.stderr, flush=True)
if result.detail:
print(result.detail, file=sys.stderr, flush=True)
return 0 if result.ok or result.state == "cancelled" else 1
if __name__ == "__main__":
raise SystemExit(main())

Some files were not shown because too many files have changed in this diff Show more