mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-21 22:29:35 +00:00
Merge pull request #1931 from ouroboros-agent/claude/test-health-20261016
Tests: one process owner per run, a tiered commit gate and a critical cleanup
This commit is contained in:
commit
e4ab6cabbb
152 changed files with 4066 additions and 2880 deletions
104
.github/workflows/ci.yml
vendored
104
.github/workflows/ci.yml
vendored
|
|
@ -1,7 +1,7 @@
|
|||
# Ouroboros CI — Five-tier cross-platform testing and release pipeline
|
||||
#
|
||||
# Tier 1 (Quick): Push to ouroboros or PR targeting ouroboros → Ubuntu-only tests
|
||||
# Tier 2 (Full): Every PR and every ouroboros push → Windows/macOS; stable / manual / tag → full 3-OS matrix
|
||||
# Tier 2 (Full): Every ouroboros push → Windows/macOS (PRs: selected scope, pr-selected-os); stable / manual / tag → full 3-OS matrix
|
||||
# Tier 1.5 (SQLite floor): Push to ouroboros or PR targeting ouroboros → store modules on ubuntu-22.04's system SQLite 3.37.2
|
||||
# Tier 2.5 (Integration): Manual / tag (branch pushes: provider-canary-push.yml) → Real-provider tests, reusable provider-canary.yml (~2 min)
|
||||
# Tier 2.6 (Skill smoke): Push to ouroboros-stable / manual / tag → LIVE OuroborosHub official-skill install smoke (3-OS, ~5 min) + review→grant→enable-persistence flow on one cheap reviewer slot (ubuntu-only step, OPENROUTER_API_KEY, ~$1.2/run)
|
||||
|
|
@ -116,9 +116,8 @@ jobs:
|
|||
# The hermetic commit gate runs this exact suite (ouroboros/preflight_node.py);
|
||||
# keeping the step here keeps gate and CI mirrored in both directions.
|
||||
- name: Run browser-module tests (node --test — mirrored by the hermetic commit gate)
|
||||
if: ${{ !cancelled() && steps.setup_node.outcome == 'success' }}
|
||||
shell: bash
|
||||
run: cd web && node --test tests/*.test.js
|
||||
if: ${{ !cancelled() && steps.setup_python.outcome == 'success' && steps.setup_node.outcome == 'success' && github.event_name != 'pull_request' }}
|
||||
run: python scripts/run_tests.py --node
|
||||
# ESLint `no-undef` is the second, independent layer of the browser no-undef
|
||||
# gate (owner decision D-13): the hermetic commit gate keeps the dependency-free
|
||||
# acorn walker in the suite above; this layer is CI-only, lockfile-frozen
|
||||
|
|
@ -134,18 +133,25 @@ jobs:
|
|||
# observed platform: the serial pass has no per-test timeout (the commit gate's
|
||||
# serial pass has none either), so the ceiling is what bounds a hang there. Its
|
||||
# expiry fails that step, and with it the job, without cancelling the job: the
|
||||
# evidence step below still names the test. On Linux and macOS the killed step's
|
||||
# pytest can linger beside the later steps until the runner cleans up the job.
|
||||
# evidence step below still names the test. `scripts/run_tests.py` owns each pass
|
||||
# (lifeline guardian, overload stop), so a killed step leaves no tree behind.
|
||||
- name: Run tests (parallel — excludes the costly marker lanes AND the serial real-process suites)
|
||||
id: tests_parallel
|
||||
timeout-minutes: 90
|
||||
if: ${{ !cancelled() && steps.setup_python.outcome == 'success' }}
|
||||
run: python -m pytest tests/ -m "not serial and not integration and not browser and not ui_browser and not ui_browser_docker and not portable_detail and not skill_smoke and not size_ratchet" -n auto --dist loadscope --max-worker-restart=0 --timeout=300 --timeout-method=thread -q --tb=short --ci-evidence-dir="${{ runner.temp }}/ci-evidence/parallel"
|
||||
if: ${{ !cancelled() && steps.setup_python.outcome == 'success' && github.event_name != 'pull_request' }}
|
||||
run: python scripts/run_tests.py tests/ -m "not serial and not integration and not browser and not ui_browser and not ui_browser_docker and not portable_detail and not skill_smoke and not size_ratchet" -n auto --dist loadscope --max-worker-restart=0 --timeout=300 --timeout-method=thread -q --tb=short --ci-evidence-dir="${{ runner.temp }}/ci-evidence/parallel"
|
||||
- name: Run tests (serial — real subprocess/port/global-state suites that flake under -n)
|
||||
id: tests_serial
|
||||
timeout-minutes: 60
|
||||
if: ${{ !cancelled() && steps.setup_python.outcome == 'success' }}
|
||||
run: python -m pytest tests/ -m "serial and not integration and not browser and not ui_browser and not ui_browser_docker and not portable_detail and not skill_smoke and not size_ratchet" -q --tb=short --ci-evidence-dir="${{ runner.temp }}/ci-evidence/serial"
|
||||
if: ${{ !cancelled() && steps.setup_python.outcome == 'success' && github.event_name != 'pull_request' }}
|
||||
run: python scripts/run_tests.py tests/ -m "serial and not integration and not browser and not ui_browser and not ui_browser_docker and not portable_detail and not skill_smoke and not size_ratchet" -q --tb=short --ci-evidence-dir="${{ runner.temp }}/ci-evidence/serial"
|
||||
- name: Run PR tests (contract floor + affected modules and complete Node lane)
|
||||
id: tests_selected
|
||||
timeout-minutes: 150
|
||||
if: ${{ !cancelled() && steps.setup_python.outcome == 'success' && steps.setup_node.outcome == 'success' && github.event_name == 'pull_request' }}
|
||||
env:
|
||||
PR_BASE: ${{ github.event.pull_request.base.sha }}
|
||||
run: python scripts/run_tests.py --selected --base "$PR_BASE" --sequential --evidence-dir="${{ runner.temp }}/ci-evidence"
|
||||
# Whom the reference-book rule binds (scripts/book_growth_mode.py): the size
|
||||
# lane below blocks, warns or passes a grown book by this mode.
|
||||
- name: Decide the reference-book growth mode
|
||||
|
|
@ -190,7 +196,63 @@ jobs:
|
|||
uses: ./.github/actions/test-evidence
|
||||
with:
|
||||
name: quick-test
|
||||
passes: parallel=${{ steps.tests_parallel.outcome }} serial=${{ steps.tests_serial.outcome }} size=${{ steps.tests_size.outcome }}
|
||||
passes: parallel=${{ github.event_name == 'pull_request' && (steps.tests_selected.outputs.parallel_outcome || steps.tests_selected.outcome) || steps.tests_parallel.outcome }} serial=${{ github.event_name == 'pull_request' && (steps.tests_selected.outputs.serial_outcome || steps.tests_selected.outcome) || steps.tests_serial.outcome }} size=${{ steps.tests_size.outcome }}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Pull requests on Windows and macOS: the same selected scope as quick-test
|
||||
# (contract floor, affected modules, complete Node lane), so a platform break
|
||||
# in the changed area shows before the merge. The full matrix runs after it;
|
||||
# on Windows a full escalation runs the floor and leaves the rest to its four landed shards (--defer-full).
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
pr-selected-os:
|
||||
if: ${{ github.event_name == 'pull_request' && github.base_ref == 'ouroboros' }}
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
OUROBOROS_PREFLIGHT_REQUIRE_PLUGINS: "1"
|
||||
OUROBOROS_PREFLIGHT_REQUIRE_NODE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
id: setup_python
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
if: ${{ !cancelled() }}
|
||||
id: setup_node
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Set up Windows POSIX test utilities
|
||||
if: ${{ !cancelled() && runner.os == 'Windows' }}
|
||||
id: posix_tools
|
||||
uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2
|
||||
with:
|
||||
msystem: MSYS
|
||||
release: false
|
||||
update: true
|
||||
install: rsync
|
||||
- name: Select Windows POSIX test utilities
|
||||
id: select_posix_tools
|
||||
if: ${{ !cancelled() && runner.os == 'Windows' && steps.posix_tools.outcome == 'success' }}
|
||||
shell: pwsh
|
||||
run: '"${{ steps.posix_tools.outputs.msys2-location }}\usr\bin" >> $env:GITHUB_PATH'
|
||||
- name: Run PR tests (contract floor + affected modules and complete Node lane)
|
||||
id: tests_selected
|
||||
timeout-minutes: 150
|
||||
if: ${{ !cancelled() && steps.setup_python.outcome == 'success' && steps.setup_node.outcome == 'success' && (runner.os != 'Windows' || (steps.posix_tools.outcome == 'success' && steps.select_posix_tools.outcome == 'success')) }}
|
||||
run: python scripts/run_tests.py --selected --base ${{ github.event.pull_request.base.sha }} --sequential --evidence-dir="${{ runner.temp }}/ci-evidence" ${{ runner.os == 'Windows' && '--defer-full' || '' }}
|
||||
- name: Publish test evidence
|
||||
if: ${{ !cancelled() && steps.setup_python.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/test-evidence
|
||||
with:
|
||||
name: pr-selected-${{ matrix.os }}
|
||||
passes: parallel=${{ steps.tests_selected.outputs.parallel_outcome || steps.tests_selected.outcome }} serial=${{ steps.tests_selected.outputs.serial_outcome || steps.tests_selected.outcome }}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Benchmark methodology tests (owner decision R42). The devtools/benchmarks
|
||||
|
|
@ -255,7 +317,7 @@ jobs:
|
|||
run: /usr/bin/python3 -I -S scripts/safe_test.py --temp-parent "${{ runner.temp }}" -- .venv/bin/python -m pytest tests/test_store.py tests/test_store_import.py tests/test_store_switch.py tests/test_custody_store_rows.py tests/test_store_cutover.py tests/test_store_activation.py -q --tb=short --timeout=300 --timeout-method=thread
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Tier 2: Windows/macOS for every PR and every landed `ouroboros` push (Ubuntu is
|
||||
# Tier 2: Full Windows/macOS for every landed `ouroboros` push (Ubuntu is
|
||||
# quick-test there); full 3-OS matrix for stable, manual runs and tags. The landed
|
||||
# push is what tells a later PR that a Windows/macOS failure is inherited. It runs
|
||||
# in this repository only: an install that pushes its own commits to a private copy
|
||||
|
|
@ -266,7 +328,6 @@ jobs:
|
|||
github.ref == 'refs/heads/ouroboros-stable'
|
||||
|| github.event_name == 'workflow_dispatch'
|
||||
|| startsWith(github.ref, 'refs/tags/v')
|
||||
|| (github.event_name == 'pull_request' && github.base_ref == 'ouroboros')
|
||||
|| (github.event_name == 'push' && github.ref == 'refs/heads/ouroboros' && github.repository == 'razzant/ouroboros')
|
||||
# The growth-mode step reads the pull request's label events.
|
||||
permissions:
|
||||
|
|
@ -275,7 +336,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ${{ fromJSON((github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/ouroboros')) && '["macos-latest"]' || '["ubuntu-latest","macos-latest"]') }}
|
||||
os: ${{ fromJSON((github.event_name == 'push' && github.ref == 'refs/heads/ouroboros') && '["macos-latest"]' || '["ubuntu-latest","macos-latest"]') }}
|
||||
shard: [0] # Unsharded macOS/Ubuntu; Windows alone is split by whole file.
|
||||
include:
|
||||
- {os: windows-latest, shard: 1}
|
||||
|
|
@ -316,12 +377,10 @@ jobs:
|
|||
shell: pwsh
|
||||
run: '"${{ steps.posix_tools.outputs.msys2-location }}\usr\bin" >> $env:GITHUB_PATH'
|
||||
# Independent checks keep running after a red sibling; setup outcomes suppress cascaded noise.
|
||||
# The hermetic commit gate runs this exact suite (ouroboros/preflight_node.py);
|
||||
# `shell: bash` keeps the glob working on the Windows runner too.
|
||||
# The hermetic commit gate runs this exact suite (ouroboros/preflight_node.py).
|
||||
- name: Run browser-module tests (node --test — mirrored by the hermetic commit gate)
|
||||
if: ${{ !cancelled() && steps.setup_node.outcome == 'success' && (runner.os != 'Windows' || (steps.posix_tools.outcome == 'success' && steps.select_posix_tools.outcome == 'success')) && matrix.shard <= 1 }}
|
||||
shell: bash
|
||||
run: cd web && node --test tests/*.test.js
|
||||
if: ${{ !cancelled() && steps.setup_python.outcome == 'success' && steps.setup_node.outcome == 'success' && (runner.os != 'Windows' || (steps.posix_tools.outcome == 'success' && steps.select_posix_tools.outcome == 'success')) && matrix.shard <= 1 }}
|
||||
run: python scripts/run_tests.py --node
|
||||
# ESLint `no-undef` is the second, independent layer of the browser no-undef
|
||||
# gate (owner decision D-13): the hermetic commit gate keeps the dependency-free
|
||||
# acorn walker in the suite above; this layer is CI-only, lockfile-frozen
|
||||
|
|
@ -349,17 +408,18 @@ jobs:
|
|||
--collect-only -q --default-suite-manifest
|
||||
--ci-evidence-dir="${{ runner.temp }}/ci-evidence/default-manifest/serial"
|
||||
# A command-line `-m` REPLACES the pyproject addopts markexpr, so repeat the default
|
||||
# marker-lane exclusions and AND them with the serial split. --timeout guards hangs.
|
||||
# marker-lane exclusions and AND them with the serial split. --timeout guards hangs;
|
||||
# `scripts/run_tests.py` passes the arguments through and owns the process tree.
|
||||
- name: Run tests (parallel — excludes the costly marker lanes AND the serial real-process suites)
|
||||
id: tests_parallel
|
||||
timeout-minutes: 90
|
||||
if: ${{ !cancelled() && steps.setup_python.outcome == 'success' && (runner.os != 'Windows' || (steps.posix_tools.outcome == 'success' && steps.select_posix_tools.outcome == 'success')) }}
|
||||
run: python -m pytest tests/ -m "not serial and not integration and not browser and not ui_browser and not ui_browser_docker and not portable_detail and not skill_smoke and not size_ratchet" -n auto --dist loadscope --max-worker-restart=0 --timeout=300 --timeout-method=thread -q --tb=short --ci-evidence-dir="${{ runner.temp }}/ci-evidence/parallel" ${{ runner.os == 'Windows' && format('--default-suite-shard={0}/4', matrix.shard) || '' }}
|
||||
run: python scripts/run_tests.py tests/ -m "not serial and not integration and not browser and not ui_browser and not ui_browser_docker and not portable_detail and not skill_smoke and not size_ratchet" -n auto --dist loadscope --max-worker-restart=0 --timeout=300 --timeout-method=thread -q --tb=short --ci-evidence-dir="${{ runner.temp }}/ci-evidence/parallel" ${{ runner.os == 'Windows' && format('--default-suite-shard={0}/4', matrix.shard) || '' }}
|
||||
- name: Run tests (serial — real subprocess/port/global-state suites that flake under -n)
|
||||
id: tests_serial
|
||||
timeout-minutes: 60
|
||||
if: ${{ !cancelled() && steps.setup_python.outcome == 'success' && (runner.os != 'Windows' || (steps.posix_tools.outcome == 'success' && steps.select_posix_tools.outcome == 'success')) }}
|
||||
run: python -m pytest tests/ -m "serial and not integration and not browser and not ui_browser and not ui_browser_docker and not portable_detail and not skill_smoke and not size_ratchet" -q --tb=short --ci-evidence-dir="${{ runner.temp }}/ci-evidence/serial" ${{ runner.os == 'Windows' && format('--default-suite-shard={0}/4', matrix.shard) || '' }}
|
||||
run: python scripts/run_tests.py tests/ -m "serial and not integration and not browser and not ui_browser and not ui_browser_docker and not portable_detail and not skill_smoke and not size_ratchet" -q --tb=short --ci-evidence-dir="${{ runner.temp }}/ci-evidence/serial" ${{ runner.os == 'Windows' && format('--default-suite-shard={0}/4', matrix.shard) || '' }}
|
||||
# Whom the reference-book rule binds (scripts/book_growth_mode.py): the size
|
||||
# lane below blocks, warns or passes a grown book by this mode.
|
||||
- name: Decide the reference-book growth mode
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ MAX_LANES = 6
|
|||
STAGGER_BOUNDS = (2.0, 3.0)
|
||||
# The commit gate's hermetic pytest pass runs INSIDE each lane server and resolves ``-n auto`` to the host's
|
||||
# CPU count (128 here) with no ceiling: the paid run of 2026-09-04 started >= 104 xdist workers per self-mod
|
||||
# lane, three lanes at once, on a shared host. The runtime's own lever (``preflight_runner._PREFLIGHT_WORKERS_ENV``,
|
||||
# lane, three lanes at once, on a shared host. The runtime's own lever (``test_parallelism.WORKERS_ENV``,
|
||||
# floor 2 so PREFLIGHT_PARALLELISM_LOST can never trip) is set in this process and forwarded by ``IsolatedServer``
|
||||
# in settings-authoritative mode (its keep-list); the budget is split evenly across the concurrent lanes so the
|
||||
# whole stand stays within the shared-host rule of at most 16 pytest workers.
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ diff, but it is not loaded as a standalone checklist the way the Change Review
|
|||
|---|-------|-----|
|
||||
| 1 | `VERSION`, `web/package.json`, `web/modules/api_types.js` (`GATEWAY_CONTRACT_VERSION`), the `README.md` badge, the named direct-download links (`README.md`, `site/install/index.html`, `docs/install/index.html`), the latest `README.md` history row, and the `docs/ARCHITECTURE.md` header — are they all carrying the *author-facing* spelling (for example `4.50.0-rc.3`)? And do `pyproject.toml` and the `uv.lock` root version carry the **PEP 440 canonical form** of that same version (for example `4.50.0rc3`)? | `read_file` each file before editing. Never reconstruct version strings from memory — the in-context copy may be stale. The `VERSION` vs `pyproject.toml` divergence is intentional: `pyproject.toml` must satisfy PEP 440 so pip / build / twine accept it, while `VERSION` / tags / README / ARCHITECTURE use the author-facing spelling. `tests/test_packaging_sync.py::test_version_file_and_pyproject_are_synced` enforces the relationship via `ouroboros.tools.release_sync._normalize_pep440`. |
|
||||
| 2 | Preparing any commit → is `VERSION` bumped? | Under BIBLE.md P9, every commit is a release. A `VERSION` bump is mandatory for every commit, including docs/config/memory changes. Update every carrier together — `VERSION`, `pyproject.toml`, `uv.lock`, `web/package.json`, `web/modules/api_types.js`, the `README.md` badge / history row / download links, both install pages' download links, `docs/ARCHITECTURE.md` header; `ouroboros/tools/release_sync.py::version_carrier_desyncs()` checks the file carriers (the git tag follows on commit). Exception: a version-neutral contribution keeps every carrier byte-identical — an external contribution commit (P9 / CONTRIBUTING.md; the maintainer's integration commit performs the bump), or a commit prepared in a body candidate, which P9 lets the installation adopt locally as it is. Choose that form or a numbered release deliberately and state it; never a partial bump. |
|
||||
| 3 | New or changed logic → does an existing or newly staged test assert on the specific scenario it introduces? | Name the scenario your code handles in plain words. If no test asserts on THAT named scenario, write or update one now. "Tests exist for the module" is not the same as "tests cover this new behavior". |
|
||||
| 3 | New or changed logic → does an existing or newly staged test assert on the specific scenario it introduces? | Name the scenario in plain words and the test that distinguishes it; extend that test, write a new one only when none does, and retire assertions the change supersedes in the same diff. A pin of wording, source text, call presence or AST shape does not cover behaviour: review owns meaning. "Tests exist for the module" is not "tests cover this behaviour". |
|
||||
| 4 | Shared log / memory / replay format changed? | Grep every reader and writer first. JSONL logs (`events.jsonl`, `task_reflections.jsonl`, replay indexes), durable state files (`advisory_review.json`, `review_continuations/*.json`), and memory note/history pairs (a knowledge note / its `knowledge_history.jsonl` records) must stay coherent across every consumer. |
|
||||
| 5 | New validation guard, input filter, or edge-case check? | Before the first commit attempt, name three concrete ways it could break: wrong bounds, legitimate inputs it silently blocks, platform-specific edge cases. If you cannot name three, think longer. One honest minute here is cheaper than one reviewer round. |
|
||||
| 6 | New tool added? | `get_tools()` exports it, its schema description says WHEN to choose it (each profile receives its visible schema set every round, so the schema is the SSOT of the per-tool contract; `prompts/SYSTEM.md` is the cross-tool selection policy and mentions a tool only when the change alters that policy, never as a catalog entry; mechanism documentation lives in ARCHITECTURE/DEVELOPMENT), the handler signature matches the declared schema, and (if it mutates repo state) it is routed through the reviewed commit path rather than ad-hoc `run_command`. Also give it one explicit safety policy, its `ToolEntry.safety_policy` or an `ouroboros/safety.py::TOOL_POLICY` entry (`skip` for trusted built-ins, `check` for opaque or outward-facing ones) — `test_tool_policy_covers_all_builtin_tools` fails otherwise, and a tool without one pays a light-model LLM call per invocation. |
|
||||
|
|
@ -170,7 +170,7 @@ diff, but it is not loaded as a standalone checklist the way the Change Review
|
|||
| 15 | Changing `supervisor/git_ops.py`, `launcher.py`, `server.py`, `ouroboros/tools/review_helpers.py`, `ouroboros/tools/git.py`, tests, or evolution scheduling/checkpoint code? | Prove two invariants before review spend: (1) pytest/preflight cannot mutate the live repo or live `data/` (`OUROBOROS_DATA_DIR` / `OUROBOROS_SETTINGS_PATH` must be isolated, and `OUROBOROS_MANAGED_BY_LAUNCHER` must not leak into test subprocesses); (2) autonomous restart/reset cannot erase active evolution work — it must either land a reviewed local commit or preserve a rescue/transaction recovery pointer and pause/stop the campaign. |
|
||||
| 16 | Changing `devtools/`? | Keep operator tools isolated from runtime and package discovery: no runtime, web, or release imports from `devtools/`; no generated outputs under `repo/` or live `data/`; no secrets printed or committed. A touched devtool is reviewable executable code even when unrelated devtools stay compact in broad review packs. Domain-specific launch, routing, and methodology guidance stays with the relevant devtool. |
|
||||
| 17 | Diff spawns OS processes (`subprocess.Popen` / `mp.Process` without a bounded wait)? | Route it through `ouroboros.process_custody.spawn_supervised` (or `record_process` write-through) with an explicit `scope` (`task`/`session`/`daemon`) so the orphan reaper can find it; `tests/test_process_custody.py` enforces the allowlist. |
|
||||
| 18 | New/changed **test** spawns a real OS process, binds a real port, or mutates a module-level global/registry? | CI **and the hermetic commit gate** (`preflight_runner.py`) both run the suite as a parallel `-m "not serial" -n auto` pass plus a serial pass. Mark such a test `@pytest.mark.serial` (or add its file to `_SERIAL_TEST_FILES` in `tests/conftest.py`) — otherwise it flakes on kill/port-reclaim timing, or crashes a worker, which the gate reports as a `PARALLEL_WORKER_CRASH` **hard block** (never retried, never a flake). Static screen before you commit: grep the new test for `subprocess.Popen`/`subprocess.run`, socket or fixed-port binding, `start_new_session=True`, and un-monkeypatched module globals — but syntax cannot prove mocking, so still classify it semantically. The `serial` marker is the fix for *crashes and interference only*: the parallel pass also carries a 300s per-test timeout, and the serial pass carries none, so marking a merely-slow test `serial` moves the hang into the one lane that cannot bound it — make it faster or split it instead. Every other test must stay parallel-safe: `tmp_path` (not fixed `/tmp/...` paths), `monkeypatch.setenv`/`setattr` (not bare `os.environ[...] =`), no execution-order assumptions, reset any mutated module global. See `docs/DEVELOPMENT.md` "The commit gate mirrors the CI split". |
|
||||
| 18 | New/changed **test** spawns a real OS process, binds a real port, or mutates a module-level global/registry? | CI **and the hermetic commit gate** (`preflight_runner.py`) both use a parallel `-m "not serial" -n auto` pass plus a serial pass. Mark it `@pytest.mark.serial` (or add its file to `_SERIAL_TEST_FILES` in `tests/conftest.py`) only for a shared resource it cannot isolate — a fixed port, a real daemon, a process-global its fixture cannot restore; an owned child, a private root or port 0 is parallel-safe. Otherwise it flakes on kill/port-reclaim timing, or crashes a worker, which the gate reports as a `PARALLEL_WORKER_CRASH` **hard block** (never retried, never a flake). Syntax (`subprocess`, sockets, `start_new_session=True`, module globals) is only a screen; classify semantically. `serial` fixes crashes and interference only: the serial pass has no per-test timeout, so a merely slow test is made faster or split instead. Every other test stays parallel-safe (`docs/DEVELOPMENT.md` "Parallel CI and the `serial` marker"). |
|
||||
|
||||
Rule: read before write. Never reconstruct `VERSION`, `pyproject.toml`
|
||||
`version`, or the README badge from memory — one stale reconstruction creates
|
||||
|
|
@ -225,10 +225,10 @@ Ouroboros binds Ouroboros as the author, never the subject repository.
|
|||
| 1 | secrets_check | Are secrets, API keys, .env files, credentials present in the diff? | critical |
|
||||
| 2 | code_quality | Careful code review: bugs, logic errors, crashes, regressions, race conditions, resource leaks? | critical |
|
||||
| 3 | security_issues | Security vulnerabilities an outside input can reach: injection from model, tool, web or skill input; path traversal out of a confinement the code promises; secret leakage to a third party? Defenses against the owner or the subject's own process are not security findings. | critical |
|
||||
| 4 | tests_affected | Did code logic change without corresponding test changes? (PASS if only docs/config/memory changed, or if tests already cover the new behavior.) **Critical FAIL requires all three:** (a) name a specific behavior, code path, symbol, or failure scenario that THIS diff introduces or changes; (b) explain why existing or newly staged tests do NOT catch that specific scenario; (c) the gap is concrete, not speculative. Existing tests in any module or layer count when they exercise the changed behavior and would catch the identified failure; module or feature proximity alone is not coverage. Requiring an additional overlapping selector/unit/e2e test is only justified when a second distinct failure mode is named explicitly. If the only concern is "I'd feel better with one more test," that is advisory, not critical. | critical |
|
||||
| 4 | tests_affected | Did code logic change without corresponding test changes? (PASS if only docs/config/memory changed, or if tests already cover the new behavior.) **Critical FAIL requires all three:** (a) name a specific behavior, code path, symbol, or failure scenario that THIS diff introduces or changes; (b) explain why existing or newly staged tests do NOT catch that specific scenario; (c) the gap is concrete, not speculative. Existing tests in any module or layer count when they exercise the changed behavior and would catch the identified failure; module or feature proximity alone is not coverage. Requiring an additional overlapping selector/unit/e2e test is only justified when a second distinct failure mode is named explicitly. Wanting one more test is not a finding. Surplus direction, advisory: name a newly staged test that only pins wording, source text or shape, or duplicates an existing oracle, with the test to extend; retiring such a test is not a weakening. Public contracts, machine-read book shape and architecture boundaries stay pinned. | critical |
|
||||
| 5 | architecture_doc | Did the change REPLACE the current description of every node it touched in the subject's architecture / engineering documents (map rows, flows, invariants)? PASS when a new module, endpoint or data flow gains its one-line row and touched descriptions read as current truth; PASS with "Not applicable" when nothing architectural changed or the subject keeps no such document. A removed or relocated claim names its surviving owner or evidence of retirement; registry tests prove membership, not preservation of meaning. FAIL when the edit only appended a version stamp, a decision codename, a "used to / previously" narrative, or a second statement of a fact already owned elsewhere in the docs (a present-tense map, one home per fact). | critical |
|
||||
| 6 | cross_platform | Does the diff use platform-specific APIs (`os.kill`, `os.setsid`, `os.killpg`, `os.getpgid`, `fcntl`, `msvcrt`, `signal.SIGKILL`, `signal.SIGTERM`, `subprocess` with `start_new_session`/`creationflags`, hardcoded `/` or `\\` in filesystem paths) outside the subject's platform abstraction layer, when it has one? Does it import Unix-only or Windows-only modules (`fcntl`, `msvcrt`, `winreg`, `resource`) at any level without a platform guard (a `sys.platform` check or the subject's equivalent)? | critical |
|
||||
| 7 | capability_regression | Does the diff REMOVE or NARROW a previously-supported user-facing behavior or capability — a tool/flag/mode/path that worked before now errors or is gated tighter (e.g. a new `is_dir`/existence guard that blocks a legitimate create, a tightened allowlist that drops a real path, a removed fallback)? If so, is it INTENTIONAL and disclosed as a breaking/capability change in the commit message + changelog? Accidental capability removal is the failure class this item names. Ask whether a golden "from zero" test would have caught it. **Guard-change trigger (executable requirements, not an essay):** ADDING or TIGHTENING a guard, filter, allowlist, or deny rule IS a capability change and fires this item. For such a diff the reviewer must verify two things: (a) the diff STAGES A POSITIVE TEST that exercises a legitimate flow THROUGH the new guard and proves it still succeeds — a negative "it blocks X" test alone is insufficient (a gate can pass its own probe while breaking every real run); (b) the diff or its disclosure NAMES THE SURVIVING POSITIVE PATH — the concrete actor and flow that still work after the change. A guard change that stages no surviving-path test is a capability-regression finding, not a safety improvement. A guard keyed on model or provider identity (id, prefix, family, provider name) always fires this item; its positive test uses an identity that the diff's own literals do not contain, and a test that proves only refusal is insufficient. **Owner acceptance:** a narrowing counts as OWNER-ACCEPTED only when the subject's owner actually decided it (the owner's words or answer for this work, or a standing disclosure the subject keeps for this purpose) or it lies within authority the owner had already delegated for this work; the plan council's answers are the reviewers' view, not the owner's consent. Owner acceptance makes the finding advisory (disclosed, non-blocking). Intent wording, a commit-message disclosure, or a changelog row alone is disclosure, NOT acceptance. This names who accepts a narrowing; it adds no approval step. Severity follows the `Critical surface whitelist` below — silently removing a documented user-facing capability or a release contract is critical; removing or loosening an internal guard, proof or re-check is advisory and needs one line naming the residual; an owner-accepted narrowing or an internal-only refactor is advisory. | advisory |
|
||||
| 7 | capability_regression | Does the diff REMOVE or NARROW a previously-supported user-facing behavior or capability — a tool/flag/mode/path that worked before now errors or is gated tighter (e.g. a new `is_dir`/existence guard that blocks a legitimate create, a tightened allowlist that drops a real path, a removed fallback)? If so, is it INTENTIONAL and disclosed as a breaking/capability change in the commit message + changelog? Accidental capability removal is the failure class this item names. Ask whether a golden "from zero" test would have caught it. **Guard-change trigger (executable requirements, not an essay):** ADDING or TIGHTENING a guard, filter, allowlist, or deny rule IS a capability change and fires this item. For such a diff the reviewer must verify two things: (a) the diff names and runs a positive test, existing or newly staged, that walks a legitimate flow THROUGH the new guard and proves it still succeeds — a negative "it blocks X" test alone is insufficient (a gate can pass its own probe while breaking every real run); (b) the diff or its disclosure NAMES THE SURVIVING POSITIVE PATH — the concrete actor and flow that still work after the change. A guard change without passing surviving-path evidence is a capability-regression finding, not a safety improvement; a missing *new* test is not. A guard keyed on model or provider identity (id, prefix, family, provider name) always fires this item; its positive test uses an identity that the diff's own literals do not contain, and a test that proves only refusal is insufficient. **Owner acceptance:** a narrowing counts as OWNER-ACCEPTED only when the subject's owner actually decided it (the owner's words or answer for this work, or a standing disclosure the subject keeps for this purpose) or it lies within authority the owner had already delegated for this work; the plan council's answers are the reviewers' view, not the owner's consent. Owner acceptance makes the finding advisory (disclosed, non-blocking). Intent wording, a commit-message disclosure, or a changelog row alone is disclosure, NOT acceptance. This names who accepts a narrowing; it adds no approval step. Severity follows the `Critical surface whitelist` below — silently removing a documented user-facing capability or a release contract is critical; removing or loosening an internal guard, proof or re-check is advisory and needs one line naming the residual; an owner-accepted narrowing or an internal-only refactor is advisory. | advisory |
|
||||
| 8 | changelog_accuracy | Do the exact wording, test counts, and minor description details in the subject's changelog or release-notes entry match what the diff actually does? Wording drift, off-by-one test counts, minor inaccuracies in descriptive prose — these belong here, NOT in a release-metadata or consistency item. This item exists so reviewers have a dedicated advisory bucket for prose-level changelog imprecision that does not affect release metadata, runtime behavior, or safety contracts. (PASS with "Not applicable" when the subject keeps no changelog.) | advisory |
|
||||
| 9 | perf_lifecycle | If the diff changes data readers or an endpoint, poller, subscription, timer, startup, shutdown or other batch operation: evaluate the total read cost as history, object count and project size grow, including nested repeats, cold caches and contention for shared resources. Where that growth can materially hurt responsiveness, is there proportionate evidence at representative scale, and was redundant work or within-operation reuse considered before adding infrastructure? This remains advisory, with no universal time limit, mandatory heavy benchmark or new approval gate. Does any interaction-path read scan an unbounded store per request/message/tick (a full-table read filtered in code is such a scan)? Is a subscription/observer/interval/listener added without a paired disposer? Does O(history) work run on a poll/stream path? Does a GET handler perform new steady-state durable writes? Where the subject documents these invariants, check against its definitions rather than re-deriving them here. (PASS with "Not applicable" if the diff touches none of these surfaces.) | advisory |
|
||||
|
||||
|
|
@ -402,7 +402,7 @@ Ownership` section is delivered beside them.
|
|||
| # | item | what to check | severity when FAIL |
|
||||
|---|------|---------------|--------------------|
|
||||
| 10 | bible_compliance | Does the diff violate any BIBLE.md principle? | critical |
|
||||
| 11 | development_compliance | Does it follow DEVELOPMENT.md patterns? Check explicitly: (a) naming conventions (snake_case modules/vars, PascalCase classes, UPPER_SNAKE_CASE constants); (b) entity type rules and dependency direction — Gateway classes contain ONLY transport, no business logic; Tool functions are thin wrappers; provider- and transport-specific decisions never flow back into core policy (judged by the `Shared Contract Ownership` section delivered with this checklist); (c) module and function size follow DEVELOPMENT.md "Module Size & Complexity" (official CI enforces the gates, so a stale or growing size-debt entry is review debt to flag, not a local commit block); (d) one owner per decision — a local test of the diff that packet seats apply too: nothing the diff adds (state, branch, layer or wrapper) decides again what an existing owner decides; a derived view stays a view, never extra truth; new durable state needs an invariant that existing facts cannot reconstruct. A finding names the exact symbol, the owner or fact it duplicates and the smaller contract-preserving alternative; diff size, line count and file count alone are not findings (P7). For an added surface (module, state file, ledger, resolver, cache, retry path, tool, endpoint or background loop) name the existing mechanism `docs/ARCHITECTURE.md` maps for the need, or state its absence in one line; this judges a CHANGE, not a plan. A second home in another module is one finding: a seat asked Part 2 reports it under coupling `architecture_fit`, a packet seat here. Tracked material needs a continuing purpose (DEVELOPMENT.md "Documentation contract"): retire finished campaign machinery instead of keeping it with presence-only tests; a retired prose or spelling pin names the surviving behaviour test or the requirement that is gone; keep book-shape, public-contract and architectural-boundary checks; (e) new LLM calls go through the shared `LLMClient`/`llm.py` layer, not ad-hoc HTTP clients; (f) cognitive artifacts follow BIBLE.md P1's record/view contract: retain exact sources before replacing a body, identify partial views and their delivered ranges with resolvable sources, and preserve operative decisions, provenance and the required resident core; a partial view is disclosure, not evidence of complete review or authority; (g) new `get_tools()` exports follow the ToolEntry pattern in registry.py; (h) provider independence — no core capability (agent loop, multi-model commit review, the coupling question, memory/context flows) silently requires a second provider or OpenRouter, and every supported single direct provider keeps its model AND review-pool rows self-fillable (DEVELOPMENT.md "Provider Independence"); (i) a claimed-complete visible UI change carries vision-inspected evidence from a relevant real consumer flow; an uninspected screenshot is insufficient; states, viewports and engines are risk-selected, mobile/WebKit are not universal, and an unavailable optional engine alone is not degradation. | critical |
|
||||
| 11 | development_compliance | Does it follow DEVELOPMENT.md patterns? Check explicitly: (a) naming conventions (snake_case modules/vars, PascalCase classes, UPPER_SNAKE_CASE constants); (b) entity type rules and dependency direction — Gateway classes contain ONLY transport, no business logic; Tool functions are thin wrappers; provider- and transport-specific decisions never flow back into core policy (judged by the `Shared Contract Ownership` section delivered with this checklist); (c) module and function size follow DEVELOPMENT.md "Module Size & Complexity" (official CI enforces the gates, so a stale or growing size-debt entry is review debt to flag, not a local commit block); (d) one owner per decision — a local test of the diff that packet seats apply too: nothing the diff adds (state, branch, layer or wrapper) decides again what an existing owner decides; a derived view stays a view, never extra truth; new durable state needs an invariant that existing facts cannot reconstruct. A finding names the exact symbol, the owner or fact it duplicates and the smaller contract-preserving alternative; diff size, line count and file count alone are not findings (P7). For an added surface (module, state file, ledger, resolver, cache, retry path, tool, endpoint or background loop) name the existing mechanism `docs/ARCHITECTURE.md` maps for the need, or state its absence in one line; this judges a CHANGE, not a plan. A second home in another module is one finding: a seat asked Part 2 reports it under coupling `architecture_fit`, a packet seat here. Tracked material needs a continuing purpose (DEVELOPMENT.md "Documentation contract"): retire finished campaign machinery instead of keeping it with presence-only tests; tests retire under DEVELOPMENT.md "Retiring tests" (a verdict line each; wording pins by class, a structural scan of a live rule rewritten or kept, merges with a named survivor); keep book-shape, public-contract and architectural-boundary checks; (e) new LLM calls go through the shared `LLMClient`/`llm.py` layer, not ad-hoc HTTP clients; (f) cognitive artifacts follow BIBLE.md P1's record/view contract: retain exact sources before replacing a body, identify partial views and their delivered ranges with resolvable sources, and preserve operative decisions, provenance and the required resident core; a partial view is disclosure, not evidence of complete review or authority; (g) new `get_tools()` exports follow the ToolEntry pattern in registry.py; (h) provider independence — no core capability (agent loop, multi-model commit review, the coupling question, memory/context flows) silently requires a second provider or OpenRouter, and every supported single direct provider keeps its model AND review-pool rows self-fillable (DEVELOPMENT.md "Provider Independence"); (i) a claimed-complete visible UI change carries vision-inspected evidence from a relevant real consumer flow; an uninspected screenshot is insufficient; states, viewports and engines are risk-selected, mobile/WebKit are not universal, and an unavailable optional engine alone is not degradation. | critical |
|
||||
| 12 | version_bump | Release carriers: an ordinary commit is a numbered release (P9) — is `VERSION` bumped, does every other carrier agree with it (the file carriers `release_sync.version_carrier_desyncs()` checks, the `docs/ARCHITECTURE.md` header included) and does README carry the badge and the changelog row for the new version? A version-neutral contribution — an external contribution commit (P9 / CONTRIBUTING.md; the maintainer's integration commit bumps) or a commit prepared in a body candidate that P9 lets the installation adopt as it is — instead keeps EVERY carrier byte-identical and takes no tag. A partial carrier change fails in either form; the exception waives no other item. | critical |
|
||||
| 13 | tool_registration | New tool not exported in `get_tools()`, OR without exactly one explicit safety policy (`ToolEntry.safety_policy` or `ouroboros/safety.py::TOOL_POLICY`, guarded by `test_tool_policy_covers_all_builtin_tools`), OR a new or lowered `skip` it does not earn? (PASS if no new tool or policy change.) `skip` fits a trusted built-in whose effects host guards already fence; opaque or outward-facing tools keep `check`. | critical |
|
||||
| 14 | self_consistency | Does this change affect behavior described in `BIBLE.md`, `prompts/`, `docs/`, or this checklist itself? Check explicitly: (a) every tool name a prompt (`prompts/*.md`) mentions exists in `get_tools()` and means the same thing there — completeness is NOT required, the schemas are the catalog; a concrete prompt contradiction with BIBLE, another prompt or a book follows the shared critical threshold and surface whitelist; a prompt that restates mechanism, a tool's own description, a structurally enforced gate or a rule stated elsewhere is advisory, and a prompt that gains a sentence loses an equivalent one; a keyword, regex, branch, threshold or counter that stands in for judgment in a prompt violates P5 (DEVELOPMENT.md "LLM-first affordances"); and when the diff touches the code that assembles a prompt (`context.py`, review packet/template builders, advisory, consciousness, plan packet, loop nudges) the injected prompt text and that code still agree; (b) JSONL log/memory file formats described in `ARCHITECTURE.md` match all readers/writers; (c) any behavioral change reflected in the wake template `prompts/CONSCIOUSNESS.md` if it affects wakes; (d) DEVELOPMENT.md rules still accurate after the change. Severity must follow the shared `Critical surface whitelist` with the body carriers below — release metadata, tool schema, module map, behavioural documentation, safety guarding or frozen contracts are critical; commentary/prose/stylistic mismatches are advisory. | critical |
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ row here, count-anchored both ways.
|
|||
| `state/delegate_recovery/`, `state/delegate_recovery_transactions/` (+`active.json`), `state/delegate_supervision/` | `ouroboros/delegate_recovery.py`, `delegate_supervision.py` (+ startup sweep `delegate_state_sweep.py`) | own `schema: 1` on supervision/transactions (a returning restart adds `return_ids`/`queue_ids` via `restart_retention.prepare_restart_returns` and boot stamps `returns_restored_at` once, #1563); recovery rows fingerprinted, unversioned | terminal+age startup sweep: terminal-status recovery rows (`vetoed`/`adopted`), unreferenced transactions and settled-task supervision files past GC retention; live/resumable rows, `active.json`, no-result tasks and unreadables kept fail-closed; unreadable custody log skips the sweep | interrupted delegated runs cancelled instead of adopted; duplicate wake replay; planned handoffs vetoed |
|
||||
| `state/delegate_actor_claims/*.lock`, `state/.payload_delegation_claim.lock` | `ouroboros/delegate_custody.py`, `delegate_start_claims.py` | none — locks | unlinked on release | no durable state |
|
||||
| `state/code_intel/<root-sha>/inventory.json` | `ouroboros/code_intelligence.py` | own `schema_version: 5` (older/malformed rebuilt); hash-keyed local outlines, imports, calls, routes and coverage, plus bounded import specifiers/anchors with method/completeness; no source bodies, general occurrence lists or cross-file joins | compact rewrite on change (excl. created_at); startup prunes stale roots by mtime past GC retention; child terminal file adoption drops `state/code_intel`; imports/joins recheck bytes and current paths, incl. nested repos | pure derived cache; re-index; older caches rebuild on use (see [code navigation](code-navigation.md)) |
|
||||
| `state/preflight_receipts/<key>.json` | `ouroboros/preflight_receipts.py` `FullGateReceipt.publish` (atomic replace; written only by `preflight_runner.run_hermetic_pytest` after a complete default gate: node lane if present, parallel pass on >=2 observed xdist workers, serial pass, proven containment, executing source and installation unmoved) | own `schema: 1`; `identity` (HEAD/tree/index, workload fingerprint with interpreter and Node, Python-installation metadata, Git and executing-source digests; unbound local trust: other executables tests find, e.g. ripgrep, and package edits absent from metadata) whose canonical-JSON sha256 names the file; `authenticity: unsigned` | one file per identity on the CANONICAL data root, read only by that runner; no TTL, cap or cleanup; a later red run deletes nothing (one historical green run, not the latest attempt); unreadable or mismatched reads as absent | deletion only costs a rerun of the full battery; unsigned (owner 3A): a local writer can forge one to skip that rerun — reviewers still see the exhibit and a pull request still runs CI |
|
||||
| `state/preflight_receipts/<key>.json` | `ouroboros/preflight_receipts.py` `FullGateReceipt.publish` (atomic replace; written only by `preflight_runner.run_hermetic_pytest` after a complete full gate (the default recipe or a full-tier selection): node lane if present, parallel pass on two observed xdist workers or the one admitted, serial pass, proven containment, executing source and installation unmoved) | own `schema: 1`; `identity` (HEAD/tree/index, workload fingerprint with interpreter and Node, Python-installation metadata, Git and executing-source digests; unbound local trust: other executables tests find, e.g. ripgrep, and package edits absent from metadata) whose canonical-JSON sha256 names the file; `authenticity: unsigned` | one file per identity on the CANONICAL data root, read by that runner and by adoption of exactly its HEAD/tree (`preflight_qualification.receipt_for_commit`); no TTL, cap or cleanup; a later red run deletes nothing (one historical green run, not the latest attempt); unreadable or mismatched reads as absent | deletion only costs a rerun of the full battery; unsigned (owner 3A): a local writer can forge one to skip that rerun — reviewers still see the exhibit and a pull request still runs CI |
|
||||
| `state/extension_reconcile/` (+`failed/`) | `ouroboros/extension_reconcile_queue.py` | none — not needed (one-shot markers) | consumed by server loop; after 5 attempts moved to `failed/`, where markers age-prune past GC retention (the failure fact stays durable in events.jsonl) | pending worker→server reconciles lost; re-toggle heals |
|
||||
| `state/workspace_executor_processes/` | `ouroboros/workspace_executor.py` | own `schema_version: 1` + owner tag | unlink on stop; stale rows filtered at read (pid/cmd-sha) | service processes survive unreaped |
|
||||
| `state/acceptance_fence_acks/<token>.<req>.json` | `supervisor/events_worker_reports.py` (writer, one file per pooled-worker request), `ouroboros/agent.py` (the requester reads and unlinks only its own `req`; direct turns fence in-process and write none) | none — transport ack | inline GC on write (255 newest / 3600 s); a late ack nobody waits for ages out here | the waiting worker re-sends a transition once (a read never), then records a typed `supervisor_ack_unavailable` unknown — never read as a refusal |
|
||||
|
|
|
|||
|
|
@ -253,7 +253,8 @@ ouroboros/ — agent core and shared runtime (§6)
|
|||
review.py — Size and complexity inventory behind the shrink-only ceilings and the diff pulse (§6 Structural gates, Commit preflight)
|
||||
size_ratchet_manifest.py — Generated data-only size-debt manifest (`scripts/regenerate_size_ratchet.py`)
|
||||
review_execution_projection.py — Reviewer-execution projection; kinds `api` | `harness` | `native`, so the owner can tell a retrieving review from a packet review on the same model
|
||||
preflight_runner.py, preflight_node.py, preflight_receipts.py — Hermetic test gate, Node lane, receipts (§6 Hermetic preflight proof; §8)
|
||||
preflight_runner.py, preflight_node.py, preflight_receipts.py, test_parallelism.py — Hermetic gate, Node, receipts, worker budget (§6; §8)
|
||||
test_selection.py, preflight_inventory.py, preflight_qualification.py — Scope, execution inventory, full adoption proof (§6)
|
||||
review_substrate.py — Review seat coordinator: independent seats, per-actor records that keep transport, parse, verdict, coverage and quorum distinct (§6 Review stack, Task acceptance)
|
||||
review_custody.py, review_source_closure.py, review_operation.py, review_live_join.py — Reviewer workers: no-resend custody, retained owner-bound inputs, panel waits/checkpoints, existing-producer collection/live joins (§6 Task acceptance)
|
||||
review_owner_custody.py — Paid attempts record `(server session, pid)` (§6 Paid stamp and owner custody)
|
||||
|
|
@ -264,7 +265,7 @@ ouroboros/ — agent core and shared runtime (§6)
|
|||
review_session_custody.py, review_checking.py — Delegated-review recovery validation and the pre-POST durable invocation checkpoint; writable seats' private checking copies (§6 Review delivery)
|
||||
review_slot_cancel.py — A cancel outcome reports only what it proved; a succeeded run whose result read fails is the typed `ReviewSessionSucceededResultUnavailable`, never "may still be live"
|
||||
review_actor_aggregation.py, review_session_usage.py, review_thread_continuity.py — Completed-actor contract aggregation; delegated-session usage attribution; Claudexor thread operations for delegated plan reviewers
|
||||
commit_admission.py — Deterministic commit-admission SSOT (release checks, staged-Python syntax, `run_tests_preflight_with_proof`); the commit gate delegates here (§6 Hermetic preflight proof)
|
||||
commit_admission.py — Release/syntax admission and bound test proof (§6)
|
||||
reviewer_slot_config.py — The one review-pool builder, `review_pool_slots`, over enabled `review_eligible` catalog rows (§7 Review pool)
|
||||
review_pool_migration.py, review_pool_receipts.py — The review-lane → review-pool migration at the settings read seam and its durable receipts (the `state/review_migrations/` snapshot is the rollback source) (§11.4)
|
||||
review_run_isolation.py — Contributor review isolation before `config`: private whole-data root, pinned host settings and panel, cumulative cap, attach-only engine (§6 Delegated subagents; Monetary authority and projections)
|
||||
|
|
@ -518,7 +519,7 @@ ouroboros/ — agent core and shared runtime (§6)
|
|||
subagent_integration.py — integrate_subagent_patch (sha256, 3-way --index, protected-path gated, genesis refused), external-workspace audited verdict, `coop_already_in_tree` no-op, compare_subagent_patches
|
||||
patch_verdict.py — The one verdict writer for both patch pipelines: subjects are minted by the writer (`run_<rid>`), never prefix-matched by readers; artifact plus typed `delegate_run_patch_verdict` custody row
|
||||
delegate_start_claims.py — Serializes custody recheck and `START_REQUESTED`; transport/run waits are outside locks. Unsent retries may hold actor/payload locks, never root/store locks
|
||||
process_containment.py — Env-token container membership (`OURO_PROC_CONTAINER_*`: Linux /proc, macOS `ps -E`, Windows Job Object); an alive-or-undeterminable member is an honest hard-block answer, never a kill guarantee (§6 Delegated subagents)
|
||||
process_containment.py, process_lifeline.py, process_overload.py — Token/Job custody, POSIX owner-death guardian and RSS stop; unconfirmed cleanup blocks; supervision, not a quota (DEVELOPMENT §9)
|
||||
process_scope.py, process_census.py — work scopes (membership, records, task-end/Panic reap, engine index); kernel-state readers (§9)
|
||||
process_custody.py — `spawn_supervised` and the durable process_ledger.jsonl; `reap_orphaned_processes` with strict identity and `retained_purposes`; the parent lifeline; `stop_ledgered_processes` requires measured identity and confirmed exit (Runtime topology below; §9)
|
||||
obligations.py — Atomic current sets (incl. `owner_questions`); owning transitions publish before work and retire after discharge
|
||||
|
|
|
|||
|
|
@ -386,9 +386,9 @@ Every Advisory or Cyber Pro outcome returns its complete result and exact `revie
|
|||
|
||||
#### Hermetic preflight proof
|
||||
|
||||
Only `preflight_runner.py` mints `ctx._preflight_test_proof`, after green lanes and containment, binding HEAD, tree, index, pass specs, isolated environment, interpreter and Node. Child PATH carries selected Node; workload normalization shares the temp-parent default (DEVELOPMENT § Build & CI). Tests may read HEAD: a new commit needs a new run; skip/no-suite `None` is never green. Assembly, lanes and typed `PREFLIGHT_*` blocks: DEVELOPMENT "The commit gate mirrors the CI split"; no silent skip or serial fallback. Unproven teardown retains the temp tree and Git registration: deleting live inputs would destroy custody.
|
||||
Only `preflight_runner.py` mints `ctx._preflight_test_proof` after green lanes and containment, binding HEAD/tree/index, pass specs, isolated environment, interpreter and Node. `test_selection.py` selects floor, changed tests, direct importers and widening; `preflight_inventory.py` reconciles executed IDs, and records keep tier, base/policy, reasons and counts. Missing floor or execution blocks; verifier changes force full (DEVELOPMENT "The tiered commit and CI gates"). Unknown teardown retains the tree and Git registration.
|
||||
|
||||
A complete default run (every lane, ≥2 observed parallel workers) writes `state/preflight_receipts/<key>.json`: one historical green run, unsigned, reused by a later process or Restart. Its identity adds Git, the selected Python installation's metadata and the executing source, pinned once at code load (older loaded code tests, never mints or accepts); launch descriptors and containment tokens never miss. Unbound local trust: forgery, other executables tests find (ripgrep), package edits absent from metadata. Seats' sized, hashed briefs carry the frozen `preflight_execution` exhibit; a pending rejoin keeps it.
|
||||
Selected proof never qualifies adoption, managed updates or evolution publication; adoption and publication accept only existing full evidence of the exact commit (`preflight_qualification.py`), never a new run. Only a complete full run (all lanes, two workers or the one admitted) writes `state/preflight_receipts/<key>.json`, unsigned, reusable across processes. Identity adds Git, Python metadata and load-pinned source; older code cannot mint/accept. A new HEAD requires a run; skips grant no proof. Forgery, other executables and unrecorded package edits stay outside its trust. Seats' hashed briefs carry the frozen `preflight_execution`; pending rejoins keep it.
|
||||
|
||||
#### Commit preflight
|
||||
|
||||
|
|
|
|||
|
|
@ -10,21 +10,21 @@ The local `ouroboros-stable` ref is also a recovery fallback maintained by expli
|
|||
|
||||
### CI topology
|
||||
|
||||
`.github/workflows/ci.yml` has 24 jobs, grouped by trigger and secret exposure:
|
||||
`.github/workflows/ci.yml` has 25 jobs, grouped by trigger and secret exposure:
|
||||
|
||||
| Tier | Jobs | Trust boundary |
|
||||
|---|---|---|
|
||||
| Fork-safe PR validation | `quick-test`, `sqlite-floor` (the store modules on ubuntu-22.04's system SQLite 3.37.2), `betterleaks-platform-smoke`, `benchmark-methodology` (also `ouroboros-stable` pushes and `v*` tags) | `ouroboros` pushes, pull requests into `ouroboros`, manual runs; read-only, no provider secrets, never `pull_request_target` |
|
||||
| Fork-safe PR validation | `quick-test`, `pr-selected-os`, `sqlite-floor` (store modules on ubuntu-22.04's SQLite 3.37.2), `betterleaks-platform-smoke`, `benchmark-methodology` (also `ouroboros-stable` pushes and `v*` tags) | PRs into `ouroboros` run the selected floor + affected modules (Windows defers full escalations to landed shards); `quick-test` is full on pushes/dispatch; read-only, no provider secrets or `pull_request_target` |
|
||||
| Android source/build proof | `android-test` | Every run; `android/tests`, release tests and disposable-key APK compilation; no publisher secrets |
|
||||
| Android emulator proof | `android-emulator-smoke` | Tags or verified `android/` changes; install/start and same-UID PackageInstaller readback on an x86_64 API matrix; no publisher secrets |
|
||||
| Browser consumer proof | `ui-browser.yml`, called by `ci.yml` and `ui-browser-push.yml` | One reusable complete lane: PRs, manual runs and `v*` tags via `ci.yml`; every `ouroboros` push via `ui-browser-push.yml`, which alone owns the partial diagnostic scenario. No path filter: Makefile-, spec- and requirements-only merges are read by tests. Four `ui-shard` jobs, one `ui-manifest` collection and the `ui-smoke` verdict, which reconciles the shards against the manifest and refuses a red session. No schedule, no provider secrets |
|
||||
| Opt-in lanes, no schedule | `system-e2e-mock` (keyless; manual runs and `v*` tags, on the release bar), `e2e-live` (`OUROBOROS_E2E_LIVE_OPENROUTER_KEY`, `$30` cap) | `e2e-live` starts only from a dispatch that opts in through the `e2e_live` input; without the secret it is one summary line and green, never a pretend run. Both upload their servers' journals, never a settings file |
|
||||
| Ordinary desktop tests | `full-test-runs` → `full-test` (no secrets) | PRs and in-repo `ouroboros` pushes run Windows/macOS beside Ubuntu quick-test; stable pushes, dispatch and `v*` tags run all three OSes. Windows has four whole-file shards with both default passes, POSIX utilities and mandatory proof. Shard 1 collects unsharded manifests and runs node/ESLint, size, import and Chromium attachment/composer checks (empty/skipped fails). The verdict needs the matrix and both pass proofs; subtests stay, only parent calls count as duplicate executions. macOS/Ubuntu stay unsharded |
|
||||
| Ordinary desktop tests | `full-test-runs` → `full-test` (no secrets) | In-repo `ouroboros` pushes run Windows/macOS beside quick-test; stable pushes, dispatch and `v*` tags run all three OSes. `scripts/run_tests.py` owns every pass. Windows has four whole-file shards with both default passes, POSIX utilities and mandatory proof. Shard 1 collects unsharded manifests and runs node/ESLint, size, import and Chromium attachment/composer checks (empty/skipped fails). The verdict needs the matrix and both pass proofs; subtests stay, only parent calls count as duplicate executions. macOS/Ubuntu stay unsharded |
|
||||
| Trusted skill matrix | `skill-smoke` (`OPENROUTER_API_KEY`) | `ouroboros-stable` pushes, manual runs, `v*` tags; never pull requests |
|
||||
| Trusted provider runs | `integration-test` (required), `integration-optional`; body in `provider-canary.yml` | Manual/tag callers select disjoint markers from `credential_required`; push wrapper keeps default `all` on the three shared branches. Producers stay strict: optional RED may redden the workflow but is no release dependency. Its floor is omitted; required skips/missing block official tags. Pre-tag qualification reads required jobs and every required canary, not aggregate green. Scoped artifacts avoid collisions. No PRs/schedule; explicit secrets only |
|
||||
| Tag-only gates and release chain | `release-preflight` needs successful `full-test`, required `integration-test` and `system-e2e-mock`; a valid tag may run desktop `build` diagnostically after a red preflight. `release` additionally needs build, `skill-smoke`, `ui-smoke`, `marker-guards`, `docker-ui-smoke`, `docker-portable-test` and `windows-proof`. The latter checks/attests the final ZIP without secrets; configured `windows-sign` alone holds its signing Environment. Android proofs gate its optional pair; `vendor-package-smoke` is informational | Tags publish only after all required jobs pass. Smoke gates also run on manual dispatch without secrets. Required provider failures or inconclusive skips block publication, never hidden by diagnostic build success |
|
||||
|
||||
Quick and full-test runners enforce `size_ratchet` (local runs warn): tip manifest exactness and shrink-only transition against `OURO_SIZE_RATCHET_BASE_REF`. An unresolvable base uses the tree-verified parent; a resolvable base without a manifest fails closed, since copying one would launder debt. Each composed book is also compared with the event base; only that check skips if no base resolves. `scripts/book_growth_mode.py` derives `OURO_BOOK_GROWTH_MODE` (`block|warn|approved`) from PR metadata with `pull-requests: read` (DEVELOPMENT "Documentation contract"). Both jobs run `cd web && node --test tests/*.test.js`, mirrored by `ouroboros/preflight_node.py`. Skill review precedes downloaded plugin imports: untrusted payload code must never share provider credentials; a missing required key is red.
|
||||
Quick and full-test runners enforce `size_ratchet` (local runs warn): tip manifest exactness and shrink-only transition against `OURO_SIZE_RATCHET_BASE_REF`. An unresolvable base uses the tree-verified parent; a resolvable base without a manifest fails closed, since copying one would launder debt. Each composed book is also compared with the event base; only that check skips if no base resolves. `scripts/book_growth_mode.py` derives `OURO_BOOK_GROWTH_MODE` (`block|warn|approved`) from PR metadata with `pull-requests: read` (DEVELOPMENT "Documentation contract"). Both tiers run the complete Node lane (`preflight_node.py`). Skill review precedes downloaded plugin imports: untrusted payload code must never share provider credentials; a missing required key is red.
|
||||
|
||||
Shared UI and keyless wait/repair browser fixtures run the dirty Git candidate in an independent checkout (`tests/candidate_checkout.py`) and prove through `/api/health` and static sentinels that the served assets and imported Python came from that copy (recipe: DEVELOPMENT "Safe local verification and dirty browser candidates"). `ouroboros/test_environment.py` gives pytest, preflight and server children scrubbed temporary roots before imports.
|
||||
|
||||
|
|
|
|||
|
|
@ -145,8 +145,9 @@ Track assets with a continuing purpose (product, contributors, verification, leg
|
|||
requirements, evidence for public claims). Plans, campaign backlog, review packets and
|
||||
run receipts belong in the external work area or durable task evidence, not the
|
||||
tracked tree; a test preserving their presence gives them no product role. Retire
|
||||
campaign tooling when its purpose ends. Review enforces this, without automatic
|
||||
deletion or filename matching.
|
||||
campaign tooling when its purpose ends. Name a new test module for the behaviour or
|
||||
owner it protects, not a round, batch, issue, PR or version. Review enforces this,
|
||||
without automatic deletion or filename matching.
|
||||
|
||||
Both are reference books: an entrypoint plus one chapter file per subject under
|
||||
`docs/architecture/` or `docs/development/`. The entrypoint's ordered `## Chapters`
|
||||
|
|
|
|||
|
|
@ -38,6 +38,6 @@ The operation runs no tests. The contributor lane first runs the proposal's herm
|
|||
|
||||
A pull request into `ouroboros` leaves every version carrier byte-identical to its target (the carrier list and the one projection that writes them: ARCHITECTURE §10, invariant 2). Use `preflight_review(commit_message="...", deterministic_only=True, source="worktree" | "index")` for free release diagnostics and a separately labelled worktree book balance before review spend; it grants no freshness. Keep source failures separate from candidate findings; verify partial staging with unchanged files, index and state (`tests/test_release_metadata_diagnostics.py`; contracts: ARCHITECTURE §6 "Commit preflight"). At integration `release_sync.sync_release_metadata()` projects the chosen version; `release_metadata_findings()` checks carriers, the current README row and the P9 limits; changelog prose and trimming stay deliberate; diagnostics add no autofix. Packaging: ARCHITECTURE §8 "Build scripts".
|
||||
|
||||
Hermetic preflight isolation: §14 "The commit gate mirrors the CI split". A test rebinds every imported process-global root and fails closed on the live data root; setting `OUROBOROS_DATA_DIR` alone is not isolation. Enforced by `tests/conftest.py`. A reviewed local commit is the durability boundary of self-modification; an `origin` push and CI are follow-up signals, not prerequisites for its survival (ARCHITECTURE §8).
|
||||
Hermetic preflight isolation: §14 "The tiered commit and CI gates". A test rebinds every imported process-global root and fails closed on the live data root; setting `OUROBOROS_DATA_DIR` alone is not isolation. Enforced by `tests/conftest.py`. A reviewed local commit is the durability boundary of self-modification; an `origin` push and CI are follow-up signals, not prerequisites for its survival (ARCHITECTURE §8).
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Managed updates rewrite the live tree under owner authority while other work run
|
|||
- Record restoration intent in the existing transaction before apply and consume it on every completion (restored, preserved or incomplete); failed persistence keeps the marker, and an interrupted replay never resets or reapplies over returned or later work (`supervisor/update_merge.py`).
|
||||
- Schema 1 is the default; stash restoration and a rollback carrying stashed work need schema 2 before old code is checked out: an older strict reader refuses an interrupted new state without a reset. `stash_restored` means a recorded successful apply, never intent. Restart the actual desktop app, Docker/service or direct server, not its browser tab; in-app Restart refuses while a restoration is pending.
|
||||
- The resolver stages the full merge, tracked binaries included; review gets exact staged mode/blob/size and parent object ids, and missing metadata blocks.
|
||||
- A managed merge commit requires a green full suite on the exact candidate (“The commit gate mirrors the CI split”). Any non-commit resolver terminal rolls back and best-effort preserves the attempt on its failed-update branch; recovery needs the fresh rescue, not that branch. Boot holds a Paused commit (`postcommit_resume`); its Stop rolls back.
|
||||
- A managed merge commit requires a green full suite on the exact candidate (“The tiered commit and CI gates”). Any non-commit resolver terminal rolls back and best-effort preserves the attempt on its failed-update branch; recovery needs the fresh rescue, not that branch. Boot holds a Paused commit (`postcommit_resume`); its Stop rolls back.
|
||||
- Fresh rescue precedes every destructive rollback and boot re-materialization; a capture failure is fail-open but durably disclosed with a transaction pointer (the choke point: ARCHITECTURE §2).
|
||||
- Manual Restore fences writers and pins HEAD before reset. Promotion resolves one development SHA for local QA and remote push.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
# Process Custody Rule
|
||||
|
||||
Each work-started process has one lifetime owner able to observe/end it by measured identity: task, server generation, delegated run, test session or installation. At scope end, stop or explicitly hand its processes to a longer-lived owner, never init. Kernel-verified membership: ledger, `OURO_{INSTALL,TASK}_SCOPE_<hex>=1` marker or recorded group, never by command line. Release/terminal paths attach/settle, never provision/download/spawn/admit. Failed reads/signals mean unverified, not gone. Retire only on a recorded settled/no-census outcome (`test_task_scope_lifetime.py`, `test_exit_does_not_admit.py`).
|
||||
Each work-started process has one lifetime owner: task, server generation, delegated run, test session or installation. End or hand it to a longer-lived owner, never init. Verify identity by ledger, `OURO_{INSTALL,TASK}_SCOPE_<hex>=1` or recorded group, never command line. Terminal paths settle, never provision/spawn/admit. Failed reads/signals mean unverified; retire only on recorded settled/no-census outcomes (`test_task_scope_lifetime.py`, `test_exit_does_not_admit.py`).
|
||||
|
||||
**Spawn chokepoint.** Long-lived `Popen`/`mp.Process` without bounded same-call waits use `process_custody.spawn_supervised`; existing Popen managers immediately `record_process`. Scopes: `task|session|daemon`; installation daemons survive generations. Plain `start_parent_lifeline` entrypoints apply `lifeline_parent_env()` LAST, excluding inherited grandparent identity; multiprocessing uses its parent sentinel (`test_pid1_parent_lifeline.py`). Worker trees use only `worker_pool_lifecycle.kill_worker_tree`, sparing ledger daemon roots (`live_daemon_root_pids`); task cancel/timeout and quiescent idle retirement also keep retained services. Other supervisor `kill_pid_tree` calls are defects. Ledger/fingerprint/skill-companion exception: ARCHITECTURE §1 Runtime topology. `test_process_custody.py` allowlists bounded helpers.
|
||||
**Spawn chokepoint.** `Popen`/`mp.Process` without bounded same-call waits use `process_custody.spawn_supervised`; existing managers immediately `record_process`. Scopes: `task|session|daemon`; installation daemons survive generations. Plain `start_parent_lifeline` applies `lifeline_parent_env()` LAST, excluding grandparent identity; multiprocessing uses its parent sentinel (`test_pid1_parent_lifeline.py`). Worker kills use `worker_pool_lifecycle.kill_worker_tree`, sparing `live_daemon_root_pids`; cancel/timeout and idle retirement also keep retained services. Other supervisor `kill_pid_tree` calls are defects. Ledger/fingerprint/companion exceptions: ARCHITECTURE §1 Runtime topology. Bounded-helper allowlist: `test_process_custody.py`.
|
||||
|
||||
**Daemon stop.** `OwnedClaudexorDaemon.stop_outcome` (Panic, Restart) separates authenticated cooperative shutdown from forced signals (ARCHITECTURE §9). Read-only resolution never ensures/installs a runtime, starts a daemon, probes accounts or inherits another home's socket override. Stop uses the exact existing Node (`resolve_cli_command(require_npm=False)`), on Windows and bundled-only installs too. RPC ack or lease release proves no physical exit; an error-code string grants no signal. Bound the manager lock; keep preparation/network/exit waits outside. Bounds: `CLAUDEXOR_OPERATOR_STOP_TIMEOUT_SEC`, `CLAUDEXOR_STOP_EXIT_WAIT_SEC` (`runtime_limits`).
|
||||
**Daemon stop.** `OwnedClaudexorDaemon.stop_outcome` (Panic, Restart) separates authenticated cooperative shutdown from forced signals (ARCHITECTURE §9). Read-only resolution never provisions a runtime/daemon, probes accounts or inherits another home's socket override. Stop uses the existing Node (`resolve_cli_command(require_npm=False)`), including Windows/bundled installs. RPC ack or lease release proves no physical exit; error codes grant no signal. Bound the manager lock; keep preparation/network/exit waits outside. Bounds: `CLAUDEXOR_OPERATOR_STOP_TIMEOUT_SEC`, `CLAUDEXOR_STOP_EXIT_WAIT_SEC` (`runtime_limits`).
|
||||
|
||||
**Failed start.** Only `ExitFact.failed_without_control` latches daemon spawning; `claudexor_startup_failure.py` labels the child's own log for diagnosis, never policy. Harvest at spawn, attach and stop (`_settle_exited_child`), sharing the reap lock. Releases are the periodic sweep, explicit Refresh, live attach or a new manager; add no backoff/counter/cooldown, host heap sizing, writer-lease policy or third retrier. `NODE_OPTIONS` remains the operator escape hatch. Run settlement alone grants no retirement: `still_continuable` keeps ordinary open/unknown owner lines and Continue offers; review/persistent lifetimes stay separate. Under the retirement lock, a complete second custody read proving no unsettled project run lets only typed `project_has_threads` discharge `PROJECT_RETIRE_FAILED` as `PROJECT_RETIRED {project_kept:true}`; other outcomes stay retryable. Automatic body collection preserves these rules and owns each physical job until actual exit, without delaying Panic (ARCHITECTURE §6/§9).
|
||||
**Failed start.** Only `ExitFact.failed_without_control` latches daemon spawning. `claudexor_startup_failure.py` diagnoses the child log, never sets policy. Harvest under the reap lock at spawn/attach/stop (`_settle_exited_child`); release through sweep, Refresh, live attach or a new manager. No backoff/counter/cooldown, host heap sizing, writer-lease policy or third retrier; `NODE_OPTIONS` stays available. Settlement alone cannot retire: `still_continuable` keeps open/unknown owner lines and Continue; review/persistent lifetimes stay separate. Under the retirement lock, a complete second custody read proving no unsettled project run lets typed `project_has_threads` discharge `PROJECT_RETIRE_FAILED` as `PROJECT_RETIRED {project_kept:true}`; other outcomes stay retryable. Body collection follows this and owns jobs until exit without delaying Panic (ARCHITECTURE §6/§9).
|
||||
|
||||
**Lifetime across platforms.** Ordinary close and forced worker/server/stray cleanup spare the shared daemon's whole subtree. Never extend this to all skill companions, broaden their reserved-port/service ancestry rules or make another port a kill target; a recorded server is signalled only on its re-proved birth/command identity. Windows birth, command, Job, restart-handle and prior-generation stop claims need native Windows evidence (psutil is Windows-only; embedded/frozen imports need packaging proof); portable fixtures prove none. See ARCHITECTURE §1 "Platform substrate".
|
||||
**Platforms.** Close and forced worker/server/stray cleanup spare the shared daemon subtree. Skill-companion reserved-port/service ancestry rules stay scoped; another port grants no kill authority. Re-prove recorded servers' birth/command identity before signalling. Windows birth, command, Job, restart-handle and prior-generation stops need native evidence; portable fixtures prove none (psutil: Windows-only; frozen imports: packaging proof). ARCHITECTURE §1 "Platform substrate".
|
||||
|
||||
**Restart and Panic.** Separate stop from next-start selection; never hot-swap a pin. Planned restart stops the daemon only for a changed pin (`server_restart._stop_owned_daemon_for_new_pin`, after worker custody, before optional cleanup); never a veto (`test_planned_restart_engine_pin.py`); quiescence checks all members before releasing a writer fence (`test_process_custody_liveness.py`). Manual Restart's update gate and `safe_restart` refuse before stopping; `_stop_owned_work` follows durable no-resume flags. Reuse `request_cancel`, `kill_workers(reconcile_delegate_custody=False)`, `reconcile_orphaned_runs` over `read_owned_gateway` and `stop_outcome`; never ensure a gateway from cancel to daemon stop or infer stop outcomes from private manager errors. Unconfirmed steps keep custody and critical diagnostics, never defer or veto Restart; add no state, drain, lease or event kind: startup sweep recovers (`test_manual_restart_execution.py`). Panic is irrevocable: failed ingress start calls the executor synchronously; manager/diagnostic faults spare other requests; bounded writes precede final exit (`test_server_control_panic_daemon.py`). Test all four continuations in `test_planned_delegate_assignment.py`: S1 saved-work/queue returns bind the successor; undispatched owner-wait/planned carriers keep their own gates without interprocess once claims.
|
||||
|
||||
Out-of-process extension HTTP responses run in the child: the runner owns staging, Popen registration and cleanup; `extension_route_stream` owns pipe frames and ASGI delivery; spawn, registration, pipe shutdown and termination stay off the ASGI loop; cancellation binds to the loaded bundle before spawn; no total or pre-header deadline applies (ARCHITECTURE §3).
|
||||
Extension HTTP responses run in the child: the runner owns staging, registration and cleanup; `extension_route_stream` owns pipe frames and ASGI delivery; spawn, registration, pipe shutdown and termination stay off the ASGI loop; cancellation binds to the loaded bundle before spawn; no total or pre-header deadline applies (ARCHITECTURE §3).
|
||||
|
||||
**Test runs.** Use `ProcessContainer(lifeline=True, overload=True)`: a POSIX pipe guardian survives owner death; Windows uses the existing kill-on-close Job. Reap before release. `config.TEST_*` and `test_parallelism.py` own physical budgets; nested runners default to one slot. `overload_stop` names the largest member and ends red; a sampler the host cannot run disables only the stop, loudly. Supervision is no OS quota: RSS is summed and detached members are found by token every five seconds; token-dropping children and simultaneous guardian death remain residuals. Fault matrix: `test_process_owner.py`.
|
||||
|
|
|
|||
|
|
@ -17,24 +17,27 @@ Default local pytest excludes seven costly or environment-dependent lanes — `i
|
|||
|
||||
`skill_smoke` and `size_ratchet` tests never also carry the `serial` marker or join `_SERIAL_TEST_FILES`: the `and not <lane>` markexprs are the lane barrier, and single-lane assignment keeps each test's placement unambiguous. A new opt-in lane registers its marker in `pyproject.toml`, adds a collect-only zero-test guard in CI, and keeps the default local addopts free of network and Docker requirements.
|
||||
|
||||
### Retiring test pins
|
||||
### Retiring tests
|
||||
|
||||
Record the retired proposition and named behaviour-test survivor (or obsolete
|
||||
requirement) in PR evidence, not a tracked ledger. Run the survivor and a falsifying
|
||||
negative. CHECKLISTS Body item 11(d) owns the preservation rule and review.
|
||||
Each removed or rewritten test gets a verdict line in PR evidence, not a ledger:
|
||||
obsolete (what ended it), wording owned by review (which item), rewritten, or merged
|
||||
(the survivor). Wording pins retire by class: a re-runnable rule and a sampled
|
||||
independent read; a structural scan of a live rule is rewritten into behaviour or
|
||||
kept. A merge runs its survivor and one falsifying negative per failure mode.
|
||||
CHECKLISTS Body item 11(d) owns the review.
|
||||
|
||||
### Parallel CI and the `serial` marker
|
||||
|
||||
CI runs the default suite in two passes (`.github/workflows/ci.yml`, runners `quick-test` / `full-test-runs`): a parallel pass, `-m "not serial and <the seven lane exclusions>"` with `PARALLEL_PASS_FLAGS` (`-n auto --dist loadscope --max-worker-restart=0 --timeout=300 --timeout-method=thread`), then a flag-free serial pass, `-m "serial and <the same exclusions>"`. Windows alone adds `--default-suite-shard=K/4` (`tests/default_lane.py`): greedy packing by collected default-node count keeps whole files together across both passes, with path/index tie breaks. Shard 1 also collects unsharded `--default-suite-manifest` witnesses in fresh processes. Two rules preserve the split:
|
||||
CI runs the default suite in two passes, each owned by `scripts/run_tests.py` (`.github/workflows/ci.yml`, runners `quick-test` / `full-test-runs`): a parallel pass, `-m "not serial and <the seven lane exclusions>"` with `PARALLEL_PASS_FLAGS` (`-n auto --dist loadscope --max-worker-restart=0 --timeout=300 --timeout-method=thread`), then a flag-free serial pass, `-m "serial and <the same exclusions>"`. Windows alone adds `--default-suite-shard=K/4` (`tests/default_lane.py` packs whole files by collected count; shard 1 adds the unsharded manifest witnesses). Two rules preserve the split:
|
||||
|
||||
- Mark real-process and real-port tests, and tests that mutate process-global state without reliable fixture isolation, `@pytest.mark.serial` (or add the file to `_SERIAL_TEST_FILES` in `tests/conftest.py`): under `-n` such a test flakes on kill/reap or port-reclaim timing or crashes its worker, and with `--max-worker-restart=0` a dead worker fails its whole co-located batch as spurious failures in unrelated files.
|
||||
- Keep every other test parallel-safe so it stays in the fast pass: `tmp_path` (never a fixed path), `monkeypatch.setenv`/`delenv`/`setattr` for environment and attribute changes, no execution-order assumptions. A module-global mutation reliably snapshot-and-restored by a fixture may stay in the parallel pass (pattern: `tests/conftest.py::_isolate_workspace_executor_globals`).
|
||||
- Mark `@pytest.mark.serial` (or list the file in `_SERIAL_TEST_FILES` in `tests/conftest.py`) only for a shared resource the test cannot isolate: a fixed port, a real daemon, a process-global its fixture cannot restore. An owned child, a private root or port 0 is not one. Under `-n` such a test flakes on kill/reap or port-reclaim timing or crashes its worker, and with `--max-worker-restart=0` a dead worker fails its co-located batch. A merely slow test is made faster or split, never `serial`: that pass has no per-test timeout.
|
||||
- Keep every other test parallel-safe: `tmp_path` (never a fixed path), `monkeypatch.setenv`/`delenv`/`setattr`, no execution-order assumptions; a fixture-restored global stays parallel (`tests/conftest.py::_isolate_workspace_executor_globals`).
|
||||
|
||||
### The local battery entry point
|
||||
|
||||
`python scripts/run_tests.py` (`make test`) runs Node (missing = red `NOT_RUN`), then all default tests, even after a pass; extra pytest args select focused work. It imports the gate's lane exclusions/parallel flags. Default xdist pins serial files to `--serial-shards` groups (`_pin_lane_groups`) under the parallel timeout; `--sequential` restores CI/gate's authoritative split. Recheck overlap-only failures sequentially. `tests/test_run_tests_script.py` enforces this; local feedback mints no hermetic proof.
|
||||
`python scripts/run_tests.py` (`make test`) always runs the full Node and default Python battery; pytest arguments select focused work. `--selected [--base REF]` uses the commit selector (default base `HEAD`), prints tier/rule/module count and runs both marker passes with paths. `--extra-test PATH` or `--full-tests` widens it with `--test-reason`. Missing required Node is red `NOT_RUN`. No hermetic proof is minted.
|
||||
|
||||
`--evidence-dir DIR` passes explicit pytest `--ci-evidence-dir` paths under one absolute root: `overlapped`, `parallel`/`serial`, or `focused`; Node is unchanged. Never export the path in `PYTEST_ADDOPTS`: nested pytest could overwrite the parent's `events.jsonl`/`results.json`. A child may name its own directory; sequential reuse after producer exit remains allowed.
|
||||
Full default overlaps bounded `--serial-shards` groups; `--sequential` restores the gate split. Recheck overlap-only failures sequentially. `--evidence-dir DIR` gives each pass its own explicit `--ci-evidence-dir`; selected runs also write `selection.json` with scope and reconciled inventory. Never export evidence via `PYTEST_ADDOPTS`: nested pytest could overwrite its parent's results.
|
||||
|
||||
### Safe local verification and dirty browser candidates
|
||||
|
||||
|
|
@ -59,17 +62,19 @@ OUROBOROS_RUN_UI_SMOKE=1 OUROBOROS_EXPECT_BROWSER_ENGINES=chromium,webkit \
|
|||
|
||||
`tests/ci_evidence.py` and `.github/actions/test-evidence` report Actions producer outcomes separately from testcase counts: passing cases never override a nonzero exit, skips and absent credentials remain explicit, and raw JUnit is never uploaded. Diagnostic failures report `diagnostics_incomplete` without changing release eligibility. Shard proof is authoritative: `reconcile-shards` rejects missing/unreadable/red projections, wrong assignments, duplicate or unexecuted nodes, and nonzero sessions, using each producer's highest attempt. UI uses four `ui-shard` jobs plus `ui-manifest`; Windows default passes use independent manifests on shard 1 and `full-test` reconciliation. Proof export/upload failures also fail the producing attempt, preventing fallback to old evidence. Step ceilings fail without cancelling the job, leaving evidence steps runnable; dead xdist workers report phase `crash`. UI shards additionally stop cooperatively between tests via `--session-timeout`. The `viewport`/`inflight` dispatches in `ui-browser-push.yml` are partial diagnostics, never full proof.
|
||||
|
||||
### The commit gate mirrors the CI split
|
||||
### The tiered commit and CI gates
|
||||
|
||||
`preflight_runner.run_hermetic_pytest` mirrors CI in one disposable checkout and scrubbed root: Node (`cd web && node --test tests/*.test.js`), then parallel `not serial` and flag-free `serial`. No web suite means no Node prerequisite; a present suite never silently disappears for missing Node. `LANE_EXCLUSION_EXPR`/`PARALLEL_PASS_FLAGS` are the SSOT pinned against both CI jobs. Candidate capture/apply uses one hardened worktree-vs-`HEAD` binary diff; failure is `PREFLIGHT_CANDIDATE_ASSEMBLY`, never a test failure (binding: ARCHITECTURE §6 "Hermetic preflight proof"). The gate's dependency-free `web/tests/no_undef.test.js` acorn walker and CI's extra ESLint `no-undef` (`web/eslint.config.js`, `npm ci`) remain independent checks.
|
||||
Ordinary `commit_reviewed` runs the floor in `test_selection.FLOOR_TESTS`, changed test modules, direct AST importers of changed product modules, and authored widening. Each floor entry names its invariant; missing or empty modules block. Selection reads the assembled candidate, dirty/untracked files included, against `HEAD` before commit and `HEAD~1` afterwards. PR CI on every OS uses the same selector against the PR base; on a full escalation Windows runs the floor and leaves the rest to its landed shards.
|
||||
|
||||
`preflight_node.node_environment` projects its bundled-first Node into child PATH for the gate and `scripts/run_tests.py`, before pytest collection, retaining other entries. Arbitrary bare pytest/`safe_test` commands and explicit test-owned child environments keep their own policy. The gate fingerprints the effective PATH. Contributor rules:
|
||||
Imports are a minimum: `extra_tests` covers indirect/dynamic consumers, prompts, skills and cross-language effects; `full_tests` covers unresolved impact. Both require `test_reason`, retained in the commit record. Verifier changes force full: runner/selector, floor, shared test helpers or data, pytest configuration, and dependency locks beyond a release bump. Product fan-out alone never triggers a threshold. Records name tier, candidate/base/policy and reasons, with execution counts; missing or duplicate execution blocks.
|
||||
|
||||
- The candidate cannot weaken the pass: `PYTEST_*`, `NODE_OPTIONS` and owner runtime state (`OUROBOROS_*`, secret-suffixed keys and every settings key `config.apply_settings_to_env` projects) are scrubbed, so the verdict cannot depend on the operator's install profile; required plugins are probed outside candidate control; post-commit checks also inspect `HEAD~1`, so suite deletion cannot hide after the commit exists; exit status owns the verdict, rendered diagnostics do not. `OUROBOROS_PREFLIGHT_SERIAL=1` is the explicit temporary rollback lever, never a silent fallback.
|
||||
- A red post-commit gate is warning-only for an ordinary commit (the local commit is preserved for forensics); evolution publication refuses to auto-push while the warning stands; inside a managed update the gate blocks boot promotion and routes through rollback, and an incomplete rollback leaves `gate_blocked` so boot retries recovery instead of promoting the rejected merge.
|
||||
- The managed mandate is "the full suite provably ran green on the exact committed tree", not "run it twice": reuse is the runner's process-held proof or, once a process loaded the merge, its unsigned receipt of a complete default run — never `tests_evidence` or the event log. A new commit needs a new run; a skip, no applicable suite or a mocked `None` mints neither.
|
||||
- Process containment is unconditional, after a green pass too: Windows uses a kill-on-close Job Object; POSIX uses an environment membership token plus a process-group backstop, and promises honest detection with a fail-closed verdict for attributed members, not guaranteed teardown of an arbitrary detached process (`tests/test_preflight_process_containment.py`). A crashed worker, a timeout-killed worker, a missing plugin, containment failure and ordinary test failure keep distinct diagnostics.
|
||||
- Mark process, port and global-state tests `serial`; make a merely slow test faster or split it, because the gate's and CI's serial pass has no per-test timeout and a serial test consumes the remaining total gate budget.
|
||||
Full qualification remains mandatory for managed updates, evolution publication and exact-commit adoption (existing full evidence only), and CI on `ouroboros` merges, stable pushes, tags and dispatch. A selected proof never mints `FullGateReceipt` or satisfies these consumers. Full receipts may reuse a complete run of the same HEAD/tree/index/workload and installation; a new HEAD needs a new run. Skip/no-suite `None` grants no proof. Ordinary post-commit failures preserve the local commit with a warning; evolution refuses publication, and managed updates roll back or retain `gate_blocked` until recovery.
|
||||
|
||||
`preflight_runner.run_hermetic_pytest` runs the entire Node lane, parallel `not serial` and flag-free `serial` in one disposable checkout. `LANE_EXCLUSION_EXPR`/`PARALLEL_PASS_FLAGS` own exclusions and flags. Missing required Node/plugins blocks. `preflight_node.node_environment` puts bundled-first Node on the gate's and CLI's child PATH, which is fingerprinted; other pytest and child environments keep their policy. The `web/tests/no_undef.test.js` acorn walker and CI's lockfile-frozen ESLint (`web/eslint.config.js`, `npm ci`) remain independent checks.
|
||||
|
||||
Candidate capture uses one hardened worktree-vs-`HEAD` binary diff; assembly failure is `PREFLIGHT_CANDIDATE_ASSEMBLY`. It scrubs `PYTEST_*`, `NODE_OPTIONS`, settings and credentials; plugin probes run outside candidate control. Post-commit baselines include `HEAD~1` to catch suite deletion; exit status owns the verdict. `OUROBOROS_PREFLIGHT_SERIAL=1` is an explicit rollback lever, never silent fallback.
|
||||
|
||||
Containment is checked even after green (§9 "Test runs"); unknown or surviving members block and retain the tree. Worker crash, timeout, missing plugin, containment and test failures keep distinct diagnoses. Proof binding: ARCHITECTURE §6.
|
||||
|
||||
### GitHub Actions: secrets in step-level `if:` conditions
|
||||
|
||||
|
|
|
|||
|
|
@ -127,11 +127,11 @@ def authorize(ctx: Any, commit: str, *, reason: str) -> Dict[str, Any]:
|
|||
|
||||
mode = effective_runtime_mode(get_runtime_mode(), getattr(ctx, "task_metadata", None))
|
||||
return authorize_row(canonical_data_root(ctx), row, commit, reason=reason, runtime_mode=mode,
|
||||
task_id=str(getattr(ctx, "task_id", "") or ""))
|
||||
task_id=str(getattr(ctx, "task_id", "") or ""), ctx=ctx)
|
||||
|
||||
|
||||
def authorize_row(data_dir: pathlib.Path, row: Dict[str, Any], commit: str, *, reason: str,
|
||||
runtime_mode: str = "advanced", task_id: str = "") -> Dict[str, Any]:
|
||||
runtime_mode: str = "advanced", task_id: str = "", ctx: Any = None) -> Dict[str, Any]:
|
||||
"""The checks and the durable handoff; shared by the tool and the evolution supervisor path."""
|
||||
from ouroboros import body_candidate
|
||||
|
||||
|
|
@ -192,6 +192,13 @@ def authorize_row(data_dir: pathlib.Path, row: Dict[str, Any], commit: str, *, r
|
|||
if current.get("phase") in _PENDING and current.get("phase") != "authorized":
|
||||
raise AdoptionRefused(
|
||||
"ADOPTION_PENDING", f"adoption {current.get('id')} is {current.get('phase')}; it must settle first.")
|
||||
from ouroboros.preflight_qualification import adoption_test_evidence
|
||||
|
||||
# Existing full green evidence of exactly this commit; adoption never runs the battery itself.
|
||||
try:
|
||||
qualification = adoption_test_evidence(data_dir, candidate, cand, ctx=ctx)
|
||||
except (ValueError, OSError, subprocess.SubprocessError) as exc:
|
||||
raise AdoptionRefused("ADOPTION_TESTS_REQUIRED", str(exc)) from exc
|
||||
directory = helper_dir(data_dir)
|
||||
if directory.exists():
|
||||
_archive(data_dir, current, f"replaced:{current.get('phase')}" if current else "stale_helper")
|
||||
|
|
@ -203,6 +210,7 @@ def authorize_row(data_dir: pathlib.Path, row: Dict[str, Any], commit: str, *, r
|
|||
"candidate_path": str(candidate), "task_id": task_id, "reason": str(reason or ""),
|
||||
"switch": rows, "deps_command": _dependency_command(serving, rows), "authorized_at": utc_now_iso(),
|
||||
"unreviewed_commits": unreviewed, "attempts": 0, "events": [],
|
||||
"tests": qualification,
|
||||
}
|
||||
# The helper is captured from the body that is running, never from the candidate.
|
||||
shutil.copyfile(pathlib.Path(body_switch.__file__), directory / "switch.py")
|
||||
|
|
|
|||
|
|
@ -354,6 +354,19 @@ class PreflightTestProof(NamedTuple):
|
|||
index_tree: str
|
||||
workload: tuple
|
||||
head: str
|
||||
selection: object = None
|
||||
|
||||
@property
|
||||
def tier(self) -> str:
|
||||
if self.selection is not None:
|
||||
return self.selection.tier
|
||||
from ouroboros.preflight_runner import DEFAULT_PYTEST_ARGS
|
||||
|
||||
specs = self.workload[0]
|
||||
complete = ([spec[0] for spec in specs] == ["parallel", "serial"]
|
||||
and all(spec[1][0] == "tests/" for spec in specs))
|
||||
legacy = len(specs) == 1 and tuple(specs[0][1]) == tuple(DEFAULT_PYTEST_ARGS)
|
||||
return "full" if complete or legacy else "selected"
|
||||
|
||||
def covers(self, candidate: PreflightTestProof | None) -> bool:
|
||||
return candidate is not None and self == candidate
|
||||
|
|
@ -390,6 +403,7 @@ def log_preflight_test_proof(ctx, proof: PreflightTestProof, *, reused: bool, ph
|
|||
from a receipt keeps that receipt as its provenance; any other row lends nothing.
|
||||
"""
|
||||
from ouroboros.preflight_receipts import canonical_data_root
|
||||
from ouroboros.preflight_inventory import scope_record
|
||||
|
||||
fingerprint = workload_fingerprint(proof.workload)
|
||||
prior = getattr(ctx, "_preflight_test_disclosure", None)
|
||||
|
|
@ -405,6 +419,7 @@ def log_preflight_test_proof(ctx, proof: PreflightTestProof, *, reused: bool, ph
|
|||
"pass_seconds": dict(passes or ()), "budget_sec": proof.workload[1],
|
||||
"head": proof.head, "tree": proof.tree, "index_tree": proof.index_tree,
|
||||
"workload_fingerprint": fingerprint,
|
||||
**scope_record(proof, getattr(ctx, "_preflight_test_inventory", None)),
|
||||
**({"receipt": receipt} if receipt is not None else {}),
|
||||
**({"empty_lanes": list(empty_lanes)} if isinstance(empty_lanes, list) else {}),
|
||||
}
|
||||
|
|
@ -421,7 +436,7 @@ def log_preflight_test_proof(ctx, proof: PreflightTestProof, *, reused: bool, ph
|
|||
|
||||
|
||||
def preflight_test_workload(
|
||||
repo, *, timeout=None, pytest_args=None, passes=None, agent_python=None, probe_module="",
|
||||
repo, *, timeout=None, pytest_args=None, passes=None, agent_python=None, probe_module="", selection=None,
|
||||
) -> tuple:
|
||||
"""Effective runner inputs, with generated paths and probe names normalized."""
|
||||
import sys
|
||||
|
|
@ -429,19 +444,25 @@ def preflight_test_workload(
|
|||
from ouroboros.preflight_node import candidate_node_tests, resolve_node
|
||||
from ouroboros.process_containment import CONTAINMENT_ENV_PREFIX
|
||||
from ouroboros.test_environment import default_temp_parent
|
||||
from ouroboros.preflight_inventory import MODULE, selection_passes
|
||||
from ouroboros.test_parallelism import requested_workers
|
||||
|
||||
base = default_temp_parent() / "ouroboros-preflight-contract"
|
||||
env = pr._preflight_env(base, base / "repo", create=False)
|
||||
# The workload names the requested worker count. The count free memory admits
|
||||
# is an execution fact frozen in the run's own probe, so a memory sample never
|
||||
# changes what a proof covers, and naming the workload reads no memory at all.
|
||||
env = pr._preflight_env(base, base / "repo", create=False, workers=requested_workers(os.cpu_count() or 1))
|
||||
# Containment membership stays in the child env (an outer reap must see the
|
||||
# tree), but its keys are per-launch nonces, not test inputs.
|
||||
environment = hashlib.sha256(json.dumps({key: value for key, value in env.items()
|
||||
if not key.startswith(CONTAINMENT_ENV_PREFIX)},
|
||||
sort_keys=True).encode()).hexdigest()
|
||||
python = agent_python or os.environ.get("OUROBOROS_AGENT_PYTHON") or sys.executable or "python3"
|
||||
specs = pr._preflight_pass_specs(pytest_args) if passes is None else passes
|
||||
specs = passes if passes is not None else (selection_passes(selection) if selection else pr._preflight_pass_specs(pytest_args))
|
||||
node_tests = tuple(candidate_node_tests(repo))
|
||||
return (
|
||||
tuple((p.label, tuple(pr._WORKER_PROBE_MODULE if probe_module and arg == probe_module else arg
|
||||
tuple((p.label, tuple(pr._WORKER_PROBE_MODULE if probe_module and arg == probe_module else
|
||||
MODULE if arg.startswith(MODULE + "_") else arg
|
||||
for arg in p.args), p.parallel) for p in specs),
|
||||
pr._resolve_preflight_timeout(pr._DEFAULT_PREFLIGHT_TIMEOUT_SEC if timeout is None else timeout),
|
||||
environment, _executable_identity(python),
|
||||
|
|
@ -451,7 +472,7 @@ def preflight_test_workload(
|
|||
|
||||
|
||||
def capture_preflight_test_subject(
|
||||
repo, *, timeout=None, pytest_args=None, passes=None, agent_python=None, probe_module="",
|
||||
repo, *, timeout=None, pytest_args=None, passes=None, agent_python=None, probe_module="", selection=None,
|
||||
) -> PreflightTestProof | None:
|
||||
"""Describe the actual checkout before execution, or decline reuse.
|
||||
|
||||
|
|
@ -475,26 +496,43 @@ def capture_preflight_test_subject(
|
|||
head = head.stdout.strip()
|
||||
workload = preflight_test_workload(
|
||||
repo, timeout=timeout, pytest_args=pytest_args, passes=passes,
|
||||
agent_python=agent_python, probe_module=probe_module,
|
||||
agent_python=agent_python, probe_module=probe_module, selection=selection,
|
||||
)
|
||||
return PreflightTestProof(tree, index_tree, workload, head)
|
||||
return PreflightTestProof(tree, index_tree, workload, head, selection)
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError, TypeError, ValueError):
|
||||
log.debug("test workload could not be bound; no proof reuse", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def preflight_test_proof_matches(ctx, repo) -> bool:
|
||||
def preflight_test_proof_matches(ctx, repo, *, require_full=True) -> bool:
|
||||
proof = getattr(ctx, "_preflight_test_proof", None)
|
||||
return isinstance(proof, PreflightTestProof) and proof.covers(capture_preflight_test_subject(repo))
|
||||
if not isinstance(proof, PreflightTestProof) or (require_full and proof.tier != "full"):
|
||||
return False
|
||||
from ouroboros.test_selection import refresh_selection
|
||||
|
||||
selection = refresh_selection(pathlib.Path(repo), proof.selection) if proof.selection else None
|
||||
return proof.covers(capture_preflight_test_subject(repo, selection=selection))
|
||||
|
||||
|
||||
def preflight_test_workload_unchanged(proof, repo, *, timeout, pytest_args) -> bool:
|
||||
try:
|
||||
return proof.workload == preflight_test_workload(repo, timeout=timeout, pytest_args=pytest_args)
|
||||
return (pytest_args is not None or proof.tier == "full") and proof.workload == preflight_test_workload(
|
||||
repo, timeout=timeout, pytest_args=pytest_args)
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError, TypeError, ValueError):
|
||||
return False # inability to bind reuse is not a failed test
|
||||
|
||||
|
||||
def selected_workload_unchanged(proof, repo, *, timeout, selection) -> bool:
|
||||
"""Selected proof reuse is separate from the full-only managed workload check."""
|
||||
from ouroboros.test_selection import refresh_selection
|
||||
|
||||
try:
|
||||
return selection == refresh_selection(pathlib.Path(repo), selection) and proof.workload == preflight_test_workload(
|
||||
repo, timeout=timeout, selection=selection)
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def run_tests_preflight_with_proof(ctx: ToolContext, *, runner) -> Optional[str]:
|
||||
"""Run the caller's seam; only the hermetic runner can attest execution.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
"""
|
||||
Ouroboros — Shared configuration (single source of truth).
|
||||
|
||||
Paths, the settings-file lifecycle (locked load/normalize/save plus environment
|
||||
projection) and mode-aware write authority. The vocabularies it reads through —
|
||||
shipped defaults, closed scales, model slots, reviewer routes, numeric limits —
|
||||
live in sibling leaves and are re-exported here, so ``ouroboros.config`` remains
|
||||
the one import surface for settings knowledge.
|
||||
"""Shared configuration: paths, locked settings lifecycle and mode-aware writes.
|
||||
Defaults, scales, model slots, routes and limits live in sibling leaves and are
|
||||
re-exported here: one import surface for settings knowledge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -137,6 +132,12 @@ from ouroboros.runtime_limits import (
|
|||
)
|
||||
from ouroboros.update_channels import UPDATE_SETTINGS_DEFAULTS, normalize_update_channel # noqa: F401
|
||||
|
||||
# Physical safety budgets, not test-value judgments or kernel-enforced quotas.
|
||||
TEST_WORKER_PEAK_RSS_BYTES = 1024**3 # Provisional 1 GiB; loaded P0 observed 690 MiB/worker.
|
||||
TEST_MEMORY_RESERVE_FRACTION = 0.10 # Keep physical headroom for the OS and other work.
|
||||
TEST_PROCESS_SAMPLE_SEC = 1.0 # Poll latency; allocations between samples can exceed the bound.
|
||||
TEST_PROCESS_TOKEN_SCAN_SEC = 5.0 # Detached members are found by token this often; the scan reads every environment.
|
||||
|
||||
|
||||
def resolve_task_execution_timeout_sec(task: dict, *, global_ceiling=_CURRENT_TASK_CEILING) -> Optional[float]:
|
||||
"""The leaf's bound arithmetic with this facade's (patchable) configured ceiling."""
|
||||
|
|
|
|||
|
|
@ -758,16 +758,17 @@ def _file_artifact(kind: str, path: pathlib.Path, **facts: Any) -> Dict[str, Any
|
|||
"size": path.stat().st_size if path.exists() else 0, **facts}
|
||||
|
||||
|
||||
def finalize_task_artifacts(parent_drive_root: pathlib.Path, task: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Write patch/memory-export artifacts for a completed headless task under the task's custody lock, the
|
||||
one lock every canonical-store publisher takes (copy-back, ref retry, settlement, mailbox cleanup), so no
|
||||
two publishers place or list one task's files at once; a held lock is ``CustodyBusy`` (nothing failed)."""
|
||||
def finalize_task_artifacts(parent_drive_root: pathlib.Path, task: Dict[str, Any],
|
||||
*, lock_timeout_sec: Optional[float] = None) -> List[Dict[str, Any]]:
|
||||
"""Publish under the custody lock shared by copy-back, ref retry, settlement and mailbox cleanup.
|
||||
Publishers never place/list one task's files concurrently; an exhausted lock budget is ``CustodyBusy``."""
|
||||
from ouroboros.task_custody import CustodyBusy, task_custody_lock
|
||||
|
||||
task_id = str(task.get("id") or "")
|
||||
if not task_id:
|
||||
return []
|
||||
with task_custody_lock(parent_drive_root, task_id, timeout_sec=PUBLICATION_LOCK_SEC) as locked:
|
||||
budget = PUBLICATION_LOCK_SEC if lock_timeout_sec is None else lock_timeout_sec
|
||||
with task_custody_lock(parent_drive_root, task_id, timeout_sec=budget) as locked:
|
||||
if not locked:
|
||||
raise CustodyBusy(f"custody lock of {task_id} is held by another publisher")
|
||||
return _finalize_task_artifacts_locked(parent_drive_root, task, task_id)
|
||||
|
|
|
|||
151
ouroboros/preflight_inventory.py
Normal file
151
ouroboros/preflight_inventory.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""Host-written pytest inventory for selected gate attempts (never receipt authority)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
|
||||
MODULE = "ouroboros_selection_inventory"
|
||||
# Loaded explicitly by each controller; worker hooks forward their collection via
|
||||
# xdist. No candidate module imports or persisted selection manifest are needed.
|
||||
SOURCE = '''import json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
_path = Path(__RESULT_PATH__)
|
||||
_worker = False
|
||||
|
||||
def _emit(event, **fields):
|
||||
if not _worker:
|
||||
with _path.open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(dict(event=event, **fields)) + "\\n")
|
||||
|
||||
def pytest_configure(config):
|
||||
global _worker
|
||||
_worker = hasattr(config, "workerinput")
|
||||
_emit("start")
|
||||
|
||||
def pytest_collection_finish(session):
|
||||
if not _worker and session.items:
|
||||
_emit("collection", ids=[item.nodeid for item in session.items])
|
||||
|
||||
@pytest.hookimpl(optionalhook=True)
|
||||
def pytest_xdist_node_collection_finished(node, ids):
|
||||
_emit("collection", ids=list(ids))
|
||||
|
||||
def pytest_runtest_logreport(report):
|
||||
if report.when == "teardown" or report.skipped or report.failed:
|
||||
_emit("report", nodeid=report.nodeid, when=report.when, outcome=report.outcome)
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
yield
|
||||
_emit("finish", exitcode=int(session.exitstatus))
|
||||
'''
|
||||
|
||||
|
||||
def selection_passes(selection, *, probe_module="", temp_root=None):
|
||||
from ouroboros import preflight_runner as pr
|
||||
|
||||
passes = []
|
||||
for spec in pr._preflight_pass_specs(probe_module=probe_module):
|
||||
module = MODULE
|
||||
if temp_root is not None:
|
||||
module += "_" + uuid.uuid4().hex
|
||||
probe = pr._probe_dir(Path(temp_root))
|
||||
probe.mkdir(parents=True, exist_ok=True)
|
||||
result = Path(temp_root) / f"inventory-{spec.label}.jsonl"
|
||||
result.write_text("", encoding="utf-8")
|
||||
(probe / f"{module}.py").write_text(SOURCE.replace("__RESULT_PATH__", repr(str(result))), encoding="utf-8")
|
||||
args = [arg for arg in spec.args if arg != "tests/"]
|
||||
passes.append(pr._make_pass(spec.label, [*selection.targets, *args, "-p", module]))
|
||||
return passes
|
||||
|
||||
|
||||
def read_inventory(temp_root, label):
|
||||
try:
|
||||
rows = [json.loads(line) for line in
|
||||
(Path(temp_root) / f"inventory-{label}.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
collections = [row["ids"] for row in rows if row["event"] == "collection"]
|
||||
reports = [row for row in rows if row["event"] == "report"]
|
||||
result = {"expected": collections[0] if collections else [], "collections": collections,
|
||||
"executed": [r["nodeid"] for r in reports if r["when"] == "teardown"],
|
||||
"skipped": [r["nodeid"] for r in reports if r["outcome"] == "skipped"],
|
||||
"failed": [r["nodeid"] for r in reports if r["outcome"] == "failed"],
|
||||
"exitcode": rows[-1].get("exitcode") if rows else None}
|
||||
expected, executed = result["expected"], result["executed"]
|
||||
if not all(isinstance(value, list) and all(isinstance(item, str) for item in value)
|
||||
for value in (expected, executed, result["skipped"], result["failed"])):
|
||||
raise ValueError("invalid node inventory")
|
||||
problem = ""
|
||||
if any(collection != expected for collection in result["collections"]):
|
||||
problem = "worker collections disagree"
|
||||
elif len(set(expected)) != len(expected) or Counter(executed) != Counter(expected):
|
||||
problem = "expected and executed test ids differ (missing or duplicate execution)"
|
||||
elif result["exitcode"] not in (0, 5) or result["failed"]:
|
||||
problem = "test session did not finish green"
|
||||
result.pop("collections")
|
||||
return result, problem
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
return {}, f"test inventory unavailable: {exc}"
|
||||
|
||||
|
||||
_LISTED_IDS = 50 # bounded excerpt of ids worth reading; counts and the digest cover the rest
|
||||
|
||||
|
||||
def compact_inventory(inventory):
|
||||
"""Counts, a digest of the expected ids and only the ids worth reading.
|
||||
|
||||
The complete inventory stays with the run (and in the CLI's selection.json); a
|
||||
durable record repeated in events, exhibits, ledgers and handoffs never carries it.
|
||||
"""
|
||||
compact = {}
|
||||
for label, row in (inventory or {}).items():
|
||||
expected, executed = row.get("expected", []), row.get("executed", [])
|
||||
compact[label] = {
|
||||
"expected": len(expected), "executed": len(executed), "skipped": len(row.get("skipped", [])),
|
||||
"digest": hashlib.sha256("\n".join(sorted(expected)).encode("utf-8")).hexdigest(),
|
||||
"failed": row.get("failed", [])[:_LISTED_IDS],
|
||||
"missing": sorted((Counter(expected) - Counter(executed)).elements())[:_LISTED_IDS],
|
||||
"unexpected": sorted((Counter(executed) - Counter(expected)).elements())[:_LISTED_IDS],
|
||||
"exitcode": row.get("exitcode"),
|
||||
}
|
||||
return compact
|
||||
|
||||
|
||||
def scope_record(proof, inventory=None):
|
||||
selection = getattr(proof, "selection", None)
|
||||
rule = "full default suite" if proof.tier == "full" else "custom pytest selection"
|
||||
return {"tier": proof.tier, **(selection.record() if selection else {"rule": rule}),
|
||||
"candidate": proof.tree, "head": proof.head, "index_tree": proof.index_tree,
|
||||
**({"inventory": compact_inventory(inventory)} if inventory is not None else {})}
|
||||
|
||||
|
||||
def inventory_problem(selection, inventory):
|
||||
"""Every floor module must collect; opt-in-only affected modules may be excluded."""
|
||||
from ouroboros.test_selection import FLOOR_TESTS
|
||||
|
||||
collected = {node.split("::", 1)[0] for row in inventory.values() for node in row.get("expected", [])}
|
||||
missing = sorted(set(FLOOR_TESTS) - collected)
|
||||
return "floor modules collected no tests: " + ", ".join(missing) if missing else ""
|
||||
|
||||
|
||||
def record_failed_attempt(ctx, selection, subject, inventory):
|
||||
"""Keep partial execution visible without granting it any proof authority."""
|
||||
if ctx is None or selection is None or getattr(ctx, "_preflight_tests_passed", False):
|
||||
return
|
||||
from ouroboros.preflight_receipts import canonical_data_root
|
||||
from ouroboros.utils import append_jsonl, utc_now_iso
|
||||
|
||||
event = {"type": "preflight_test_attempt", "ts": utc_now_iso(), "status": "failed",
|
||||
**(scope_record(subject, inventory) if subject else selection.record())}
|
||||
ctx._preflight_test_disclosure = event
|
||||
try:
|
||||
root = canonical_data_root(ctx)
|
||||
if root:
|
||||
append_jsonl(Path(root) / "logs/events.jsonl", event)
|
||||
except Exception:
|
||||
pass # Diagnostics cannot replace the actual failure.
|
||||
|
|
@ -32,10 +32,10 @@ from ouroboros.platform_layer import probe_node_version, resolve_bundled_node
|
|||
NODE_MIN_VERSION = (20, 11)
|
||||
|
||||
# The web package boundary and its test glob. `web/package.json` runs
|
||||
# `node --test tests/*.test.js` FROM web/, and both CI jobs mirror that exact
|
||||
# step; the parity test derives the CI command from these two constants, so the
|
||||
# gate, CI, and the package script cannot drift apart silently. The gate globs
|
||||
# in Python (sorted, cross-platform) instead of relying on a shell.
|
||||
# `node --test tests/*.test.js` FROM web/; the gate and both CI jobs (through
|
||||
# `scripts/run_tests.py --node`) run `candidate_node_tests` over these two
|
||||
# constants, so the gate, CI, and the package script cannot drift apart silently.
|
||||
# The lane globs in Python (sorted, cross-platform) instead of relying on a shell.
|
||||
WEB_DIR = "web"
|
||||
TESTS_GLOB = "tests/*.test.js"
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ def run_node_tests(
|
|||
# reaped unconditionally, and a live-or-undeterminable leftover is a hard
|
||||
# block whatever the exit code said. Run from the candidate's web/ with
|
||||
# web-relative paths so imports resolve exactly as under `npm test`.
|
||||
container = ProcessContainer()
|
||||
container = ProcessContainer(lifeline=True, overload=True)
|
||||
started = time.monotonic()
|
||||
# NODE_OPTIONS admits --test-only/--test-*-pattern/--require: an inherited
|
||||
# operator value can silently green a red suite — same class the pytest
|
||||
|
|
@ -173,7 +173,7 @@ def run_node_tests(
|
|||
env = pr._preflight_env(temp_root, worktree)
|
||||
env.pop("NODE_OPTIONS", None)
|
||||
proc = container.spawn(
|
||||
[node, "--test", *files],
|
||||
[node, "--test", f"--test-concurrency={env['PYTEST_XDIST_AUTO_NUM_WORKERS']}", *files],
|
||||
cwd=str(pathlib.Path(worktree) / WEB_DIR),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
|
|
|
|||
74
ouroboros/preflight_qualification.py
Normal file
74
ouroboros/preflight_qualification.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Full exact-commit test evidence for adoption and evolution publication.
|
||||
|
||||
Neither consumer runs tests. The full battery runs where a long wait belongs (a
|
||||
reviewed commit with ``full_tests``, evolution's forced post-commit run); these
|
||||
consumers accept only its evidence, for exactly the commit in question.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
_HOW = ("a run that also carried uncommitted or untracked files does not count; commit it with full_tests=true "
|
||||
"from a clean checkout so the post-commit run tests exactly this tree, then request the restart again")
|
||||
|
||||
|
||||
def _git(repo, *args):
|
||||
return subprocess.run(["git", *args], cwd=str(repo), capture_output=True, text=True, timeout=60)
|
||||
|
||||
|
||||
def _commit_tree(repo, commit) -> str:
|
||||
tree = _git(repo, "rev-parse", "--verify", f"{commit}^{{tree}}")
|
||||
return "" if tree.returncode else tree.stdout.strip()
|
||||
|
||||
|
||||
def full_proof_error(ctx, repo, commit="") -> str:
|
||||
from ouroboros.commit_admission import preflight_test_proof_matches
|
||||
|
||||
proof = getattr(ctx, "_preflight_test_proof", None)
|
||||
try:
|
||||
# A named commit needs a run of exactly its tree: green uncommitted or untracked
|
||||
# bytes on top of it are another body.
|
||||
if (getattr(ctx, "_preflight_tests_passed", False) and proof is not None
|
||||
and (not commit or (proof.head == commit and proof.tree == proof.index_tree == _commit_tree(repo, commit)))
|
||||
and preflight_test_proof_matches(ctx, repo)):
|
||||
return ""
|
||||
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError):
|
||||
pass
|
||||
return "a full green exact-tree test proof is required; selected, skipped or stale evidence cannot qualify"
|
||||
|
||||
|
||||
def receipt_for_commit(data_dir, repo, commit) -> dict:
|
||||
"""A coherent durable receipt of a complete full run of exactly ``commit``, or ``{}``."""
|
||||
from ouroboros.preflight_receipts import RECEIPT_SCHEMA, RECEIPTS_DIR, _record_problem
|
||||
|
||||
tree = _commit_tree(repo, commit)
|
||||
if not tree:
|
||||
return {}
|
||||
# The run must have carried the Node lane exactly when this commit has web tests.
|
||||
node_ran = any(name.endswith(".test.js") for name in _git(repo, "ls-tree", "-r", "--name-only", commit, "--",
|
||||
"web/tests").stdout.split())
|
||||
for path in sorted((Path(data_dir) / "state" / RECEIPTS_DIR).glob("*.json")):
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
identity = document["identity"]
|
||||
if (identity["schema"] == RECEIPT_SCHEMA and identity["head"] == commit
|
||||
and identity["tree"] == identity["index_tree"] == tree
|
||||
and not _record_problem(document, identity, node_ran)):
|
||||
return {"source": "receipt", "key": path.stem, "head": commit, "tree": tree,
|
||||
"created_at": document["created_at"], "phase": document["phase"]}
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
def adoption_test_evidence(data_dir, repo, commit, ctx=None) -> dict:
|
||||
"""The task's own full proof of ``commit`` or a durable receipt of it; never a new run."""
|
||||
if ctx is not None and not full_proof_error(ctx, repo, commit):
|
||||
proof = ctx._preflight_test_proof
|
||||
return {"tier": "full", "source": "proof", "head": proof.head, "tree": proof.tree}
|
||||
receipt = receipt_for_commit(data_dir, repo, commit)
|
||||
if receipt:
|
||||
return {"tier": "full", **receipt}
|
||||
raise ValueError(f"no full green test run of {str(commit)[:12]} is on record; {_HOW}")
|
||||
|
|
@ -7,9 +7,10 @@ when everything matches, shown to reviewers, unsigned. This module keeps ONE
|
|||
small JSON per exact identity under the caller's canonical data root. Only
|
||||
``preflight_runner.run_hermetic_pytest`` writes it, after a complete default
|
||||
run (the node lane when the candidate carries web tests, the parallel
|
||||
``not serial`` pass green on at least two observed xdist workers, the flag-free
|
||||
serial pass, a proven containment reap), and only that runner reads it, at its
|
||||
existing reuse seam after the baseline and assembly checks.
|
||||
``not serial`` pass green on two observed xdist workers or the one admitted, the
|
||||
flag-free serial pass, a proven containment reap). That runner reads it at its
|
||||
existing reuse seam after the baseline and assembly checks; adoption reads it for
|
||||
exactly its commit (``preflight_qualification.receipt_for_commit``).
|
||||
|
||||
The identity is the process-held proof (tested HEAD, tree, index and effective
|
||||
workload), the installation the test interpreter loads (probed under the exact
|
||||
|
|
@ -219,8 +220,8 @@ def _record_problem(document: Any, identity: dict, node_ran: bool) -> str:
|
|||
|
||||
Proportional coherence, not a forgery defence (the file is unsigned): the lanes must
|
||||
be the ones THIS identity's recipe requires — the node lane exactly when the candidate
|
||||
carries web tests, a non-empty parallel lane on at least two observed workers, the
|
||||
serial lane — and the record must keep the provenance of the run that wrote it.
|
||||
carries web tests, a non-empty parallel lane on two observed workers (or the one the
|
||||
budget admitted), the serial lane — and the record must keep its run's provenance.
|
||||
"""
|
||||
if not isinstance(document, dict):
|
||||
return "not a JSON object"
|
||||
|
|
@ -231,8 +232,10 @@ def _record_problem(document: Any, identity: dict, node_ran: bool) -> str:
|
|||
lanes, seconds = document.get("lanes"), document.get("pass_seconds")
|
||||
if not isinstance(lanes, dict) or lanes.get("node") != ("passed" if node_ran else "absent"):
|
||||
return "the node lane does not match the recipe this candidate requires"
|
||||
workers = lanes.get("parallel_workers")
|
||||
if type(workers) is not int or workers < 2 or lanes.get("serial") not in ("passed", "empty"):
|
||||
# The live proof's rule: two observed workers, or the one the memory budget admitted.
|
||||
workers, admitted = lanes.get("parallel_workers"), lanes.get("admitted_workers", 2)
|
||||
if (type(workers) is not int or type(admitted) is not int or admitted < 1
|
||||
or workers < min(2, admitted) or lanes.get("serial") not in ("passed", "empty")):
|
||||
return "the parallel/serial lane evidence is incomplete"
|
||||
if (not isinstance(seconds, dict) or set(seconds) != {"parallel", "serial"}
|
||||
or any(type(value) not in (int, float) or value < 0 for value in seconds.values())):
|
||||
|
|
@ -270,6 +273,8 @@ class FullGateReceipt:
|
|||
return cls(None, {}, "no tested subject (no caller ctx, or the checkout could not be bound)")
|
||||
if [spec.label for spec in passes] != ["parallel", "serial"] or probe_module not in passes[0].args:
|
||||
return cls(None, {}, "not the complete default recipe")
|
||||
if subject.tier != "full":
|
||||
return cls(None, {}, "selected evidence cannot mint a FullGateReceipt")
|
||||
root = canonical_data_root(ctx)
|
||||
if root is None:
|
||||
return cls(None, {}, "no canonical data root")
|
||||
|
|
@ -317,9 +322,9 @@ class FullGateReceipt:
|
|||
"""Write the receipt after a complete green run whose identity held throughout.
|
||||
|
||||
Called only once every lane is green and containment proven. A source or
|
||||
installation that moved during the run, an empty parallel lane or fewer than
|
||||
two observed workers decline; a write failure is logged. Neither changes the
|
||||
verdict the runner returns.
|
||||
installation that moved during the run, an empty parallel lane or fewer
|
||||
observed workers than the gate requires (two, or the one admitted) decline; a
|
||||
write failure is logged. Neither changes the verdict the runner returns.
|
||||
"""
|
||||
from ouroboros import preflight_runner as pr
|
||||
from ouroboros.utils import atomic_write_json, utc_now_iso
|
||||
|
|
@ -327,9 +332,9 @@ class FullGateReceipt:
|
|||
if self.path is None:
|
||||
return
|
||||
try:
|
||||
workers = len(pr._observed_worker_ids(temp_root))
|
||||
workers, admitted = len(pr._observed_worker_ids(temp_root)), pr._allocated_worker_count(temp_root)
|
||||
reason = ("the parallel pass collected no tests" if "parallel" in empty_passes else
|
||||
f"{workers} observed xdist worker(s)" if workers < pr._MIN_PREFLIGHT_WORKERS else "")
|
||||
f"{workers} observed xdist worker(s)" if workers < min(pr._MIN_PREFLIGHT_WORKERS, admitted) else "")
|
||||
if not reason:
|
||||
reason = _source_status()[1]
|
||||
if not reason:
|
||||
|
|
@ -344,7 +349,7 @@ class FullGateReceipt:
|
|||
"schema": RECEIPT_SCHEMA, "identity": self.identity, "result": "passed",
|
||||
"authenticity": "unsigned",
|
||||
"lanes": {"node": "passed" if self.node_ran else "absent", "parallel_workers": workers,
|
||||
"serial": "empty" if "serial" in empty_passes else "passed"},
|
||||
"admitted_workers": admitted, "serial": "empty" if "serial" in empty_passes else "passed"},
|
||||
"pass_seconds": dict(timings), "phase": phase,
|
||||
"task_id": str(getattr(ctx, "task_id", "") or ""), "created_at": utc_now_iso(),
|
||||
})
|
||||
|
|
@ -392,7 +397,7 @@ def execution_exhibit(ctx: Any, *, otherwise: str = "") -> dict:
|
|||
"the run completed but minted no proof of the tested subject" if completed
|
||||
else "no green hermetic run (disabled or no applicable suite)")}
|
||||
try:
|
||||
bound = preflight_test_proof_matches(ctx, ctx.repo_dir)
|
||||
bound = preflight_test_proof_matches(ctx, ctx.repo_dir, require_full=False)
|
||||
except Exception:
|
||||
bound = False
|
||||
if not bound:
|
||||
|
|
@ -406,7 +411,9 @@ def execution_exhibit(ctx: Any, *, otherwise: str = "") -> dict:
|
|||
recipe = (["node"] if proof.workload[4] else []) + [str(spec[0]) for spec in proof.workload[0]]
|
||||
empty = event.get("empty_lanes")
|
||||
empty = [lane for lane in recipe if lane in empty] if isinstance(empty, list) else None
|
||||
return {"status": "passed", "authenticity": "unsigned", "evidence": evidence,
|
||||
return {"status": "passed", "authenticity": "unsigned", "evidence": evidence, "tier": proof.tier,
|
||||
**{key: event[key] for key in ("base", "policy", "rule", "files", "reasons", "extra_tests",
|
||||
"full_tests", "test_reason", "expected_files", "omitted_files", "inventory") if key in event},
|
||||
"lanes": [lane for lane in recipe if lane not in empty] if empty is not None else [],
|
||||
**({"empty_lanes": empty} if empty else {}),
|
||||
"tested": {"head": proof.head, "tree": proof.tree, "index_tree": proof.index_tree},
|
||||
|
|
@ -447,8 +454,12 @@ def execution_section(exhibit: Any) -> str:
|
|||
else "; which of its lanes ran tests is not disclosed")
|
||||
if empty:
|
||||
observed += f"; lanes {', '.join(empty)} collected no tests, so no test ran there"
|
||||
scope = ""
|
||||
if exhibit.get("tier") == "selected" and isinstance(exhibit.get("files"), list):
|
||||
scope = (f" over {len(exhibit['files'])} selected test modules ({exhibit.get('omitted_files', 0)} "
|
||||
f"not run; {exhibit.get('rule', 'floor, direct importers and widening')})")
|
||||
return ("## Preflight test execution (host fact)\n"
|
||||
f"The hermetic test gate passed{observed}.\n"
|
||||
f"The {exhibit.get('tier', 'full')} hermetic test gate passed{scope}{observed}.\n"
|
||||
f"Tested checkout: HEAD {tested.get('head')}, tree {tested.get('tree')}, index "
|
||||
f"{tested.get('index_tree')}. Evidence: {how}.\n"
|
||||
"Unsigned host fact, not a verdict: decide yourself which further checks this change needs.")
|
||||
|
|
|
|||
|
|
@ -471,37 +471,30 @@ def _copy_untracked(repo_dir: pathlib.Path, worktree: pathlib.Path) -> None:
|
|||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
# The parallel lane is only a parallel lane if xdist really starts more than one
|
||||
# worker. `-n auto` resolves through PYTEST_XDIST_AUTO_NUM_WORKERS, which the
|
||||
# operator environment can carry: an inherited "1" runs the whole pass on a
|
||||
# single worker while the argv still says `-n`, so `PreflightPass.parallel` stays
|
||||
# True, the pass returns green, and the gate has proved nothing about the
|
||||
# parallel-only defects it exists to catch. That value is therefore never
|
||||
# inherited — `_preflight_env` scrubs the whole PYTEST_* namespace and re-injects
|
||||
# the count resolved here, which is clamped so it can never fall below two.
|
||||
# Require real concurrency when the admitted budget permits it. Physical room
|
||||
# or an explicit parent allocation may admit one worker; that is not proof of
|
||||
# concurrency. The host freezes its admitted count beside the nonce probe, so
|
||||
# a later memory sample cannot change the pass's expected evidence.
|
||||
_MIN_PREFLIGHT_WORKERS = 2
|
||||
# Operator lever for the parallel pass's xdist worker count, floored at two and
|
||||
# sized per concurrent build lane by the rule in docs/DEVELOPMENT.md. It also
|
||||
# keeps the nested fixture repos in tests/test_preflight_runner.py (1-3 probe
|
||||
# tests each) from spending minutes on a full `-n auto` fan-out. Read from the
|
||||
# OPERATOR environment, never forwarded to the candidate: the scrub drops it.
|
||||
_PREFLIGHT_WORKERS_ENV = "OUROBOROS_PREFLIGHT_TEST_WORKERS"
|
||||
|
||||
|
||||
def _preflight_worker_count() -> int:
|
||||
"""How many xdist workers the parallel pass must start — always >= 2."""
|
||||
raw = os.environ.get(_PREFLIGHT_WORKERS_ENV, "")
|
||||
if raw.strip():
|
||||
try:
|
||||
return max(_MIN_PREFLIGHT_WORKERS, int(raw.strip()))
|
||||
except ValueError:
|
||||
pass
|
||||
return max(_MIN_PREFLIGHT_WORKERS, os.cpu_count() or _MIN_PREFLIGHT_WORKERS)
|
||||
"""CPU-based default, capped by physical room and a parent test allocation."""
|
||||
from ouroboros.test_parallelism import worker_budget
|
||||
|
||||
return worker_budget(os.cpu_count() or 1)
|
||||
|
||||
|
||||
def _allocated_worker_count(temp_root: pathlib.Path) -> int:
|
||||
try:
|
||||
return int((_probe_dir(temp_root) / "admitted-workers").read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return _preflight_worker_count()
|
||||
|
||||
|
||||
# Forcing the flags and the plugins still only proves what the gate ASKED for.
|
||||
# The pass is accepted as parallel only if xdist really started >= 2 workers, and
|
||||
# that is observed from inside the run: a tiny plugin the gate writes into its own
|
||||
# The pass must actually start min(2, admitted budget) workers, observed inside
|
||||
# the run: a tiny plugin the gate writes into its own
|
||||
# disposable temp root (never into the candidate worktree) records one file per
|
||||
# worker id. Reading it back is the difference between "the argv said -n" and
|
||||
# "concurrency actually happened".
|
||||
|
|
@ -559,6 +552,7 @@ def _install_worker_probe(temp_root: pathlib.Path) -> str:
|
|||
module = f"{_WORKER_PROBE_MODULE}_{uuid.uuid4().hex}"
|
||||
probe = _probe_dir(temp_root)
|
||||
probe.mkdir(parents=True, exist_ok=True)
|
||||
(probe / "admitted-workers").write_text(str(_preflight_worker_count()), encoding="utf-8")
|
||||
(probe / f"{module}.py").write_text(
|
||||
_WORKER_PROBE_SOURCE.replace("__WORKER_LOG__", repr(str(_worker_log_dir(temp_root)))),
|
||||
encoding="utf-8",
|
||||
|
|
@ -574,9 +568,11 @@ def _observed_worker_ids(temp_root: pathlib.Path) -> set:
|
|||
return set()
|
||||
|
||||
|
||||
def _preflight_env(temp_root: pathlib.Path, repo_worktree: pathlib.Path, *, create=True) -> dict:
|
||||
def _preflight_env(temp_root: pathlib.Path, repo_worktree: pathlib.Path, *, create=True,
|
||||
workers: Optional[int] = None) -> dict:
|
||||
from ouroboros.test_environment import isolated_environment, without_launch_descriptors
|
||||
from ouroboros.preflight_node import node_environment
|
||||
from ouroboros.test_parallelism import NESTED_WORKERS_ENV
|
||||
|
||||
temp_root = pathlib.Path(temp_root).resolve(strict=False)
|
||||
# The scrub drops settings keys (`test_environment.settings_keys`), secret suffixes,
|
||||
|
|
@ -590,7 +586,8 @@ def _preflight_env(temp_root: pathlib.Path, repo_worktree: pathlib.Path, *, crea
|
|||
# Re-injected AFTER the scrub, so `-n auto` resolves to a count this process
|
||||
# chose rather than one the operator environment happened to carry. Inert for
|
||||
# the serial/legacy passes, which pass no `-n` at all.
|
||||
env["PYTEST_XDIST_AUTO_NUM_WORKERS"] = str(_preflight_worker_count())
|
||||
env["PYTEST_XDIST_AUTO_NUM_WORKERS"] = str(_allocated_worker_count(temp_root) if workers is None else workers)
|
||||
env[NESTED_WORKERS_ENV] = env["PYTEST_XDIST_AUTO_NUM_WORKERS"]
|
||||
return env
|
||||
|
||||
|
||||
|
|
@ -821,7 +818,7 @@ def _execute_pytest_pass(
|
|||
"""
|
||||
from ouroboros.process_containment import ProcessContainer
|
||||
|
||||
container = ProcessContainer()
|
||||
container = ProcessContainer(lifeline=True, overload=True)
|
||||
# `container.spawn`, not `Popen` + `adopt`: it applies the process-group/session
|
||||
# kwargs AND leaves no window between the process existing and being contained.
|
||||
# On Windows job membership only takes effect at assignment, so a descendant
|
||||
|
|
@ -1199,46 +1196,30 @@ def run_hermetic_pytest(
|
|||
max_output: int = 8000,
|
||||
phase: str = "post_commit",
|
||||
ctx=None,
|
||||
selected: bool = False, base: str | None = None, extra_tests=(), full_tests=False, test_reason="",
|
||||
) -> Optional[str]:
|
||||
"""Run pytest against the candidate diff in a disposable worktree.
|
||||
"""Run Node and both pytest lanes in one disposable candidate checkout.
|
||||
|
||||
Mirrors CI: the web node lane (``preflight_node``, on candidates carrying
|
||||
``web/tests/*.test.js``), then a parallel ``not serial`` pass, then a
|
||||
``serial`` pass — one worktree/env, ONE shared total budget. Fails fast on
|
||||
the first red lane, so the output never truncates the failing section away.
|
||||
A full run is the default; reviewed ordinary commits request ``selected``.
|
||||
Selection uses the assembled candidate, HEAD before commit or HEAD~1 after,
|
||||
plus authored widening. Only complete full runs may mint durable receipts.
|
||||
Skip/no suite returns None without proof; failures return bounded diagnostics.
|
||||
OUROBOROS_PREFLIGHT_TIMEOUT_SEC owns the shared total lane budget.
|
||||
|
||||
Returns ``None`` on success or when no suite applies, otherwise an error.
|
||||
An optional caller ctx retains proof only of completed green lanes and
|
||||
containment; a matching workload reuses it, or another process's unsigned
|
||||
receipt of a complete default run (``preflight_receipts``), after the
|
||||
baseline/assembly checks; only a complete default run mints that receipt.
|
||||
``OUROBOROS_PREFLIGHT_TIMEOUT_SEC`` overrides ``timeout`` for all callers.
|
||||
|
||||
``phase`` selects the deleted-suite baseline (see ``_TESTS_BASELINE_REFS``):
|
||||
the pre-commit review passes ``PRE_COMMIT_PHASE`` so it compares against HEAD
|
||||
only, while the default post-commit verification also consults HEAD~1,
|
||||
because by then the deletion it is looking for is already in HEAD.
|
||||
|
||||
The candidate is assembled as ONE hardened ``git diff --binary … HEAD``
|
||||
capture (external drivers, textconv, colour and operator prefix configs
|
||||
pinned off; payload kept as raw bytes end to end) applied to a clean
|
||||
worktree at HEAD, plus a copy of the untracked files: an exact tracked
|
||||
projection of the live worktree plus its safe non-ignored untracked
|
||||
entries — for every source-index state, including a merge in progress,
|
||||
whose unmerged entries the former staged+unstaged diff pair rendered as
|
||||
stubs and ``--cc`` hunks that ``git apply`` dropped or rejected. The
|
||||
untracked side keeps ``_copy_untracked``'s long-standing boundaries:
|
||||
ignored files are absent, and untracked symlinks are dereferenced to
|
||||
regular files (non-file entries skipped), so the candidate is not
|
||||
literally byte-equal to the worktree in those corners. When the source index
|
||||
is merged, its exact ``write-tree`` snapshot is also installed into the
|
||||
disposable index (without updating files) and verified before tests run.
|
||||
A genuinely unmerged source index retains the live-resolution projection.
|
||||
Assembly captures one hardened, raw-byte worktree-vs-HEAD diff (including
|
||||
live merge resolutions), applies it with LF preserved, and copies non-ignored
|
||||
untracked files. Untracked symlinks are dereferenced, non-files skipped.
|
||||
A merged source index is installed and verified without changing files; an
|
||||
unmerged index retains the live-resolution projection. The phase also selects
|
||||
the deleted-suite baseline: HEAD before commit, HEAD plus HEAD~1 afterwards.
|
||||
Unconfirmed teardown retains the checkout and its Git registration.
|
||||
"""
|
||||
previous_proof = getattr(ctx, "_preflight_test_proof", None)
|
||||
previous_inventory = getattr(ctx, "_preflight_test_inventory", None)
|
||||
if ctx is not None:
|
||||
ctx._preflight_test_proof = None
|
||||
ctx._preflight_tests_passed = False
|
||||
ctx._preflight_test_inventory = None
|
||||
timeout = _resolve_preflight_timeout(timeout)
|
||||
# Checked BEFORE anything runs. `_diagnosis` renders inside this budget, so a
|
||||
# non-positive one produced an empty string for a real failure — which the
|
||||
|
|
@ -1290,11 +1271,9 @@ def run_hermetic_pytest(
|
|||
worktree = temp_root / "repo"
|
||||
worktree_added = False
|
||||
unproven_teardown = ""
|
||||
selection, subject, inventory = None, None, {}
|
||||
try:
|
||||
# The probe is written BEFORE the specs are built, and the nonce name it
|
||||
# returns is what the parallel pass loads. Building the specs first and
|
||||
# discarding the name left `-p` pointing at a stem no file carries, which
|
||||
# is a pytest usage error, not a run.
|
||||
# Install before compiling specs: -p must name the actual nonce module.
|
||||
probe_module = _install_worker_probe(temp_root)
|
||||
passes = _preflight_pass_specs(pytest_args, probe_module=probe_module)
|
||||
# Before ANY candidate code is on disk, let alone imported.
|
||||
|
|
@ -1308,12 +1287,7 @@ def run_hermetic_pytest(
|
|||
"\n".join(problems), max_output,
|
||||
)
|
||||
|
||||
# `-c core.autocrlf=false -c core.eol=lf`: check HEAD out byte-for-byte so
|
||||
# the hermetic worktree matches the bytes the `--binary` candidate diff was
|
||||
# captured against. Without it a Windows runner (`core.autocrlf=true`)
|
||||
# materializes HEAD with CRLF, and the LF candidate then applies onto a
|
||||
# CRLF base — the capture is byte-faithful but the checkout it lands on is
|
||||
# not. Paired with the same override on `_apply_diff`.
|
||||
# Preserve HEAD's LF bytes on Windows too, paired with _apply_diff.
|
||||
add = _run_git(
|
||||
repo,
|
||||
["-c", "core.autocrlf=false", "-c", "core.eol=lf",
|
||||
|
|
@ -1359,18 +1333,29 @@ def run_hermetic_pytest(
|
|||
)
|
||||
from ouroboros.commit_admission import (
|
||||
PreflightTestProof, capture_preflight_test_subject, log_preflight_test_proof,
|
||||
preflight_test_workload_unchanged,
|
||||
preflight_test_workload_unchanged, selected_workload_unchanged,
|
||||
)
|
||||
from ouroboros.preflight_receipts import FullGateReceipt
|
||||
from ouroboros.preflight_inventory import selection_passes, read_inventory, inventory_problem
|
||||
if selected:
|
||||
from ouroboros.test_selection import select_tests
|
||||
selection = select_tests(worktree, base=base or ("HEAD" if phase == PRE_COMMIT_PHASE else "HEAD~1"),
|
||||
extra_tests=extra_tests, full_tests=full_tests, test_reason=test_reason)
|
||||
if pytest_args is not None:
|
||||
raise ValueError("selected scope cannot be combined with custom pytest arguments")
|
||||
passes = selection_passes(selection, probe_module=probe_module, temp_root=temp_root)
|
||||
|
||||
subject = capture_preflight_test_subject(
|
||||
worktree, timeout=timeout, pytest_args=pytest_args,
|
||||
passes=passes, agent_python=agent_python, probe_module=probe_module,
|
||||
passes=passes, agent_python=agent_python, probe_module=probe_module, selection=selection,
|
||||
) if ctx is not None else None
|
||||
if selection is not None and ctx is not None and subject is None:
|
||||
raise ValueError("selected test subject could not be bound")
|
||||
can_reuse = isinstance(previous_proof, PreflightTestProof) and previous_proof.covers(subject)
|
||||
receipt = None if can_reuse else FullGateReceipt.prepare(ctx, subject, passes, probe_module, agent_python, temp_root, worktree)
|
||||
if can_reuse or receipt.covered():
|
||||
ctx._preflight_test_proof = previous_proof if can_reuse else subject
|
||||
ctx._preflight_test_inventory = previous_inventory if can_reuse else None
|
||||
ctx._preflight_tests_passed = True
|
||||
log_preflight_test_proof(ctx, ctx._preflight_test_proof, reused=True, phase=phase, receipt=receipt and receipt.disclosure(),
|
||||
empty_lanes=receipt and receipt.empty_lanes)
|
||||
|
|
@ -1384,23 +1369,25 @@ def run_hermetic_pytest(
|
|||
empty_passes: list[str] = []
|
||||
timings: list[tuple[str, float]] = []
|
||||
for spec in passes:
|
||||
# Keep the exact float remainder; rounding up would exceed the
|
||||
# shared total budget (including the preceding node lane).
|
||||
# Do not round the remaining shared budget up.
|
||||
remaining = timeout - (time.monotonic() - started)
|
||||
if remaining <= 0:
|
||||
return (
|
||||
f"⚠️ PRE_PUSH_TEST_ERROR: the {spec.label} pass never started — the "
|
||||
f"total budget of {timeout} seconds was exhausted by the earlier pass(es)"
|
||||
)
|
||||
# `started` sizes the shared budget; the header reports THIS pass's
|
||||
# own duration, or a fast serial pass would be blamed for the whole
|
||||
# gate's wall-clock after a slow parallel one.
|
||||
# Headers report this pass, not cumulative shared-budget time.
|
||||
pass_started = time.monotonic()
|
||||
unproven_teardown = f"the {spec.label} pass did not return, so its teardown is unconfirmed"
|
||||
returncode, output, reap_error = _execute_pytest_pass(
|
||||
agent_python, worktree, temp_root, spec.args, remaining
|
||||
)
|
||||
unproven_teardown = reap_error
|
||||
inventory_error = ""
|
||||
if selection is not None:
|
||||
inventory[spec.label], inventory_error = read_inventory(temp_root, spec.label)
|
||||
if ctx is not None:
|
||||
ctx._preflight_test_inventory = inventory
|
||||
elapsed = time.monotonic() - pass_started
|
||||
timings.append((spec.label, round(elapsed, 1)))
|
||||
if reap_error:
|
||||
|
|
@ -1431,6 +1418,8 @@ def run_hermetic_pytest(
|
|||
f"in the {spec.label} pass (total budget {timeout} seconds)",
|
||||
output, max_output,
|
||||
)
|
||||
if inventory_error and returncode in (0, _PYTEST_EXIT_NO_TESTS):
|
||||
return f"⚠️ PRE_PUSH_TEST_ERROR: PREFLIGHT_INVENTORY: {spec.label}: {inventory_error}"
|
||||
if returncode == _PYTEST_EXIT_NO_TESTS:
|
||||
empty_passes.append(spec.label)
|
||||
continue
|
||||
|
|
@ -1438,11 +1427,11 @@ def run_hermetic_pytest(
|
|||
# Use the actual nonce flag, not the stem or spec.parallel:
|
||||
# custom argv gets no host probe; red/empty lanes need no count.
|
||||
observed = _observed_worker_ids(temp_root)
|
||||
if len(observed) < _MIN_PREFLIGHT_WORKERS:
|
||||
if len(observed) < (required := min(_MIN_PREFLIGHT_WORKERS, _allocated_worker_count(temp_root))):
|
||||
return _diagnosis(
|
||||
"⚠️ PRE_PUSH_TEST_ERROR: PREFLIGHT_PARALLELISM_LOST (hard block): "
|
||||
f"the {spec.label} pass returned green on {len(observed)} xdist "
|
||||
f"worker(s), fewer than the {_MIN_PREFLIGHT_WORKERS} it requires",
|
||||
f"worker(s), fewer than the {required} its admitted budget requires",
|
||||
"A pass that never ran concurrently proves nothing about the "
|
||||
"parallel-only defects this gate exists to catch, so it is not "
|
||||
"accepted as green. Check the repository's pytest configuration for "
|
||||
|
|
@ -1470,17 +1459,23 @@ def run_hermetic_pytest(
|
|||
"⚠️ PRE_PUSH_TEST_ERROR: no tests were collected in any preflight pass — "
|
||||
"a repository with a tests/ directory must yield at least one runnable test"
|
||||
)
|
||||
if selection and (problem := inventory_problem(selection, inventory)):
|
||||
return f"⚠️ PRE_PUSH_TEST_ERROR: PREFLIGHT_INVENTORY: {problem}"
|
||||
if ctx is not None:
|
||||
ctx._preflight_tests_passed = True
|
||||
# Missing node-lane execution cannot mint a complete proof. The
|
||||
# normal producer returns rc=0 only after its containment check.
|
||||
if (subject
|
||||
and preflight_test_workload_unchanged(subject, worktree, timeout=timeout, pytest_args=pytest_args)
|
||||
and (selected_workload_unchanged(subject, worktree, timeout=timeout, selection=selection) if selection
|
||||
else preflight_test_workload_unchanged(subject, worktree, timeout=timeout, pytest_args=pytest_args))
|
||||
and (not subject.workload[4] or (node_result or {}).get("returncode") == 0)):
|
||||
ctx._preflight_test_proof = subject
|
||||
receipt.publish(ctx, temp_root, worktree, agent_python, empty_passes, phase, timings)
|
||||
log_preflight_test_proof(ctx, subject, reused=False, phase=phase, passes=timings, receipt=receipt.disclosure(),
|
||||
empty_lanes=empty_passes)
|
||||
if selection and ctx._preflight_test_proof is None:
|
||||
ctx._preflight_tests_passed = False
|
||||
raise ValueError("selected test scope changed during execution; no proof was minted")
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
failure = f"⚠️ PRE_PUSH_TEST_ERROR: pytest timed out after {timeout} seconds"
|
||||
|
|
@ -1489,5 +1484,7 @@ def run_hermetic_pytest(
|
|||
except Exception as exc:
|
||||
failure = f"⚠️ PRE_PUSH_TEST_ERROR: hermetic preflight failed: {exc}"
|
||||
finally:
|
||||
from ouroboros.preflight_inventory import record_failed_attempt
|
||||
record_failed_attempt(ctx, selection, subject, inventory)
|
||||
_release_hermetic_tree(repo, temp_root, worktree, worktree_added, unproven_teardown)
|
||||
return _with_retention_notice(failure, unproven_teardown, worktree, max_output)
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ class ProcessContainer:
|
|||
teardown — but only when the API confirms it). Prefer ``spawn`` over ``Popen`` + ``adopt``:
|
||||
POSIX ``adopt`` can neither plant the token nor vouch for a pid or group."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, *, lifeline: bool = False, overload: bool = False) -> None:
|
||||
self._job = None
|
||||
# The pid `spawn` started and the group it leads: known WITHOUT having to be read.
|
||||
self._root = 0
|
||||
|
|
@ -281,6 +281,11 @@ class ProcessContainer:
|
|||
self._setup_error = ""
|
||||
# Unique per instance: a nested preflight and its outer run never claim each other's.
|
||||
self._token = f"{CONTAINMENT_ENV_PREFIX}{uuid.uuid4().hex}"
|
||||
self._protect_owner = lifeline
|
||||
self._watch_overload = overload
|
||||
self._lifeline = self._watch = None
|
||||
self._birth = ""
|
||||
self._protection_error = ""
|
||||
|
||||
def containment_env(self) -> "dict[str, str]":
|
||||
"""Environment entries that make a process and its descendants members. ``spawn`` applies
|
||||
|
|
@ -298,6 +303,9 @@ class ProcessContainer:
|
|||
kwargs.update(group_kwargs)
|
||||
env = kwargs.get("env")
|
||||
env = dict(os.environ if env is None else env)
|
||||
# Explicit child environments must not sever an enclosing run's custody.
|
||||
env.update({key: value for key, value in os.environ.items()
|
||||
if key.startswith(CONTAINMENT_ENV_PREFIX)})
|
||||
env.pop(_LAUNCHER_AUTHORITY_ENV, None)
|
||||
env.pop(_LAUNCHER_REPO_AUTHORITY_ENV, None)
|
||||
kwargs["env"] = {**env, **self.containment_env()}
|
||||
|
|
@ -306,7 +314,18 @@ class ProcessContainer:
|
|||
self._suspended = True
|
||||
if flags:
|
||||
kwargs["creationflags"] = flags
|
||||
proc = subprocess.Popen(argv, **kwargs)
|
||||
if self._protect_owner and not _pl.IS_WINDOWS:
|
||||
from ouroboros.process_lifeline import ProcessLifeline
|
||||
self._lifeline = ProcessLifeline(self._token)
|
||||
try:
|
||||
if self._watch_overload:
|
||||
from ouroboros.process_overload import OverloadWatch
|
||||
from ouroboros.test_parallelism import nested_workers
|
||||
self._watch = OverloadWatch(self, nested_workers(kwargs["env"]))
|
||||
proc = subprocess.Popen(argv, **kwargs)
|
||||
except BaseException:
|
||||
self.reap()
|
||||
raise
|
||||
# Knowledge no scan can re-derive: enumeration claims members by READING them, so a root
|
||||
# turning nondumpable before the first scan is in no list. `start_new_session` makes it its
|
||||
# own group LEADER (pgid == pid); enumerating any OTHER pgid would report bystanders.
|
||||
|
|
@ -315,6 +334,17 @@ class ProcessContainer:
|
|||
self._pgid = pgid if pgid and pgid == self._root else 0
|
||||
self._start_ticks = _pl._proc_start_ticks(self._root)
|
||||
self.adopt(proc)
|
||||
try:
|
||||
if self._protect_owner or self._watch_overload:
|
||||
self._birth = _pl.process_start_time(self._root)
|
||||
if self._lifeline is not None:
|
||||
self._lifeline.attach(self._root, self._birth, self._pgid, self._start_ticks)
|
||||
if self._watch is not None:
|
||||
self._watch.start()
|
||||
except BaseException:
|
||||
self.reap()
|
||||
proc.wait(timeout=5)
|
||||
raise
|
||||
return proc
|
||||
|
||||
def adopt(self, proc: subprocess.Popen) -> None:
|
||||
|
|
@ -388,7 +418,46 @@ class ProcessContainer:
|
|||
undetermined.append(pid)
|
||||
return alive, undetermined, ""
|
||||
|
||||
def stop_tree(self) -> None:
|
||||
"""Stop this invocation, keeping attribution alive for the final scan."""
|
||||
if _pl.IS_WINDOWS:
|
||||
if self._job is not None:
|
||||
error = _pl.terminate_job(self._job)
|
||||
if error:
|
||||
self._protection_error = "; ".join(part for part in (self._protection_error, error) if part)
|
||||
elif self._token:
|
||||
from ouroboros.process_lifeline import kill_attributed_tree
|
||||
kill_attributed_tree(self._token, self._root, self._birth)
|
||||
|
||||
def reap(self) -> str:
|
||||
"""Stop protection, reap, and only then release the independent guardian."""
|
||||
if self._watch is not None:
|
||||
watch, self._watch = self._watch, None
|
||||
try:
|
||||
self._protection_error = watch.stop() or self._protection_error
|
||||
except Exception as exc:
|
||||
self._protection_error = f"overload watch stop unconfirmed: {exc}"
|
||||
error = ""
|
||||
try:
|
||||
if self._protect_owner or self._watch_overload:
|
||||
try:
|
||||
self.stop_tree()
|
||||
except Exception as exc:
|
||||
error = f"contained tree stop unconfirmed: {exc}"
|
||||
try:
|
||||
error = "; ".join(part for part in (error, self._reap()) if part)
|
||||
except Exception as exc:
|
||||
error = "; ".join(part for part in (error, f"container reap unconfirmed: {exc}") if part)
|
||||
finally:
|
||||
if self._lifeline is not None:
|
||||
lifeline, self._lifeline = self._lifeline, None
|
||||
try:
|
||||
error = lifeline.finish(released=not error) or error
|
||||
except Exception as exc:
|
||||
error = "; ".join(part for part in (error, f"lifeline release unconfirmed: {exc}") if part)
|
||||
return "; ".join(part for part in (self._protection_error, error) if part)
|
||||
|
||||
def _reap(self) -> str:
|
||||
"""SCAN the container; return "" only when nothing of it is left, else why not. Detection,
|
||||
not guaranteed teardown. ONE bounded best-effort kill sweep runs first; after it nothing is
|
||||
ever signalled again, so a member is targeted at most once and the unavoidable
|
||||
|
|
@ -433,6 +502,8 @@ class ProcessContainer:
|
|||
|
||||
def close(self) -> None:
|
||||
"""Release the container handle. Inert after ``reap``, which closes it itself."""
|
||||
if self._lifeline is not None or self._watch is not None:
|
||||
self.reap()
|
||||
if _pl.IS_WINDOWS and self._job is not None:
|
||||
_pl.close_job(self._job)
|
||||
self._job = None
|
||||
|
|
|
|||
144
ouroboros/process_lifeline.py
Normal file
144
ouroboros/process_lifeline.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""Invocation-scoped POSIX cleanup after a ProcessContainer owner dies.
|
||||
|
||||
The guardian is outside the workload's session. Only the owner holds the pipe's
|
||||
write end; EOF survives SIGKILL and never depends on a Python finally block.
|
||||
Windows needs no guardian: ProcessContainer's non-inheritable kill-on-close Job
|
||||
handle is closed by the kernel on owner death, including TerminateProcess.
|
||||
This is supervision, not a sandbox: a detached child that discards its token
|
||||
before observation, or killing both owner and guardian, can defeat POSIX cleanup.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
from ouroboros import platform_layer as pl
|
||||
|
||||
# Bounds on the guardian protocol/exit, not on the workload's useful lifetime.
|
||||
_HANDSHAKE_SEC = 10.0
|
||||
_GUARDIAN_EXIT_SEC = 30.0
|
||||
|
||||
|
||||
def kill_attributed_tree(token: str, root: int = 0, birth: str = "") -> None:
|
||||
"""End the measured root's tree, then token-bearing detached descendants.
|
||||
|
||||
Neither a stale pid/pgid nor a command-name match grants signal authority.
|
||||
POSIX still has the unavoidable exit/reuse race between validation and kill;
|
||||
the existing container's survivor scan reports unconfirmed cleanup.
|
||||
"""
|
||||
from ouroboros.process_containment import (
|
||||
ENV_ASSIGNMENT_PRESENT, pid_environment_assignment_state, pids_with_env_marker,
|
||||
)
|
||||
|
||||
if root > 0 and birth and pl.process_start_time(root) == birth:
|
||||
pl.kill_process_tree(SimpleNamespace(pid=root))
|
||||
members = pids_with_env_marker(token)
|
||||
if members is None:
|
||||
raise RuntimeError("lifeline could not enumerate the contained process tree")
|
||||
for pid in members:
|
||||
if pid != os.getpid() and pid_environment_assignment_state(pid, token, "1") == ENV_ASSIGNMENT_PRESENT:
|
||||
pl.force_kill_pid(pid)
|
||||
|
||||
|
||||
class ProcessLifeline:
|
||||
"""An owner-held pipe; release only after the workload has been reaped."""
|
||||
|
||||
def __init__(self, token: str):
|
||||
from ouroboros.process_containment import ProcessContainer
|
||||
|
||||
# This container owns the guardian itself, with a DIFFERENT token. The
|
||||
# watched token travels over stdin, never argv/environment (which would
|
||||
# make the guardian attribute itself or its ps helpers to the workload).
|
||||
self._container = ProcessContainer()
|
||||
self._proc = None
|
||||
try:
|
||||
self._proc = self._container.spawn(
|
||||
[sys.executable, "-I", "-B", "-c",
|
||||
"import sys; sys.path.insert(0, sys.argv[1]); "
|
||||
"from ouroboros.process_lifeline import _guardian_main; _guardian_main()",
|
||||
str(Path(__file__).resolve().parents[1])],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
)
|
||||
ready, _, _ = select.select([self._proc.stdout], [], [], _HANDSHAKE_SEC)
|
||||
if not ready or self._proc.stdout.read(1) != b"R":
|
||||
raise RuntimeError("process lifeline failed to start")
|
||||
self._proc.stdout.close()
|
||||
self._send({"token": token})
|
||||
except BaseException:
|
||||
self.finish(released=False)
|
||||
raise
|
||||
|
||||
def _send(self, message: dict) -> None:
|
||||
self._proc.stdin.write((json.dumps(message) + "\n").encode("utf-8"))
|
||||
self._proc.stdin.flush()
|
||||
|
||||
def attach(self, pid: int, birth: str, pgid: int, since: int) -> None:
|
||||
self._send({"root": pid, "birth": birth, "pgid": pgid, "since": since})
|
||||
|
||||
def finish(self, *, released: bool) -> str:
|
||||
proc, self._proc = self._proc, None
|
||||
if proc is None:
|
||||
return ""
|
||||
error = ""
|
||||
try:
|
||||
if proc.stdin is not None and not proc.stdin.closed:
|
||||
try:
|
||||
if released:
|
||||
proc.stdin.write(b'{"release":true}\n')
|
||||
proc.stdin.flush()
|
||||
except (BrokenPipeError, OSError) as exc:
|
||||
error = f"process lifeline lost: {exc}"
|
||||
finally:
|
||||
proc.stdin.close()
|
||||
# EOF asks for cleanup; normal release asks for stand-down. Wait
|
||||
# either way, so successful test runs never accumulate guardians.
|
||||
try:
|
||||
code = proc.wait(timeout=_GUARDIAN_EXIT_SEC)
|
||||
if code:
|
||||
error = error or f"process lifeline exited {code}"
|
||||
except subprocess.TimeoutExpired:
|
||||
error = "process lifeline did not exit after pipe closure"
|
||||
finally:
|
||||
error = self._container.reap() or error
|
||||
self._container.close()
|
||||
if proc.stdout is not None:
|
||||
proc.stdout.close()
|
||||
proc.wait(timeout=5)
|
||||
return error
|
||||
|
||||
|
||||
def _guardian_main() -> None:
|
||||
"""Private pipe protocol: initialize, optionally identify root, release/EOF."""
|
||||
token, root, birth, pgid, since = "", 0, "", 0, 0
|
||||
sys.stdout.buffer.write(b"R")
|
||||
sys.stdout.buffer.flush()
|
||||
for line in sys.stdin.buffer:
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except (ValueError, UnicodeError):
|
||||
# Owner may have died during its final write. Keep the last complete
|
||||
# ownership record; the token was delivered before workload spawn.
|
||||
continue
|
||||
if record.get("release"):
|
||||
return
|
||||
token = record.get("token", token)
|
||||
root, birth = record.get("root", root), record.get("birth", birth)
|
||||
pgid, since = record.get("pgid", pgid), record.get("since", since)
|
||||
if token:
|
||||
kill_attributed_tree(token, root, birth)
|
||||
# Reuse the same bounded verification as a live owner, including
|
||||
# detached grandchildren created while the first tree sweep ran.
|
||||
from ouroboros.process_containment import ProcessContainer
|
||||
|
||||
container = ProcessContainer()
|
||||
container._token, container._root = token, root
|
||||
container._pgid, container._start_ticks = pgid, since
|
||||
error = container.reap()
|
||||
if error:
|
||||
print(f"PROCESS_LIFELINE_UNCONFIRMED {error}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1)
|
||||
181
ouroboros/process_overload.py
Normal file
181
ouroboros/process_overload.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""Sample an owned test tree and stop it before it consumes physical headroom.
|
||||
|
||||
RSS is summed (shared pages may be counted more than once). This intentionally
|
||||
conservative admission/stop budget is not an OS memory/PID/file-descriptor quota.
|
||||
Rapid growth between samples can exceed it, particularly on macOS.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from ouroboros import platform_layer as pl
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemberMemory:
|
||||
pid: int
|
||||
rss_bytes: int
|
||||
name: str
|
||||
|
||||
|
||||
def _in_job(pid: int, job) -> bool:
|
||||
"""Windows membership comes from the held Job, including reparented members."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
def read(handle):
|
||||
answer = wintypes.BOOL()
|
||||
if not pl._kernel32.IsProcessInJob(handle, job, ctypes.byref(answer)):
|
||||
raise OSError(ctypes.get_last_error(), "IsProcessInJob failed")
|
||||
return bool(answer.value)
|
||||
|
||||
error, member = pl._windows_query(pid, read)
|
||||
if error == 87: # The process exited while the process table was read.
|
||||
return False
|
||||
if error:
|
||||
return False # An inaccessible stranger is not an attributed member.
|
||||
return bool(member)
|
||||
|
||||
|
||||
def token_members(container) -> set[int]:
|
||||
"""Members that carry the container's token, detached ones included: one whole-table scan."""
|
||||
from ouroboros.process_containment import pids_with_env_marker
|
||||
|
||||
pids = pids_with_env_marker(container._token)
|
||||
if pids is None:
|
||||
raise RuntimeError("contained process membership is unavailable")
|
||||
return set(pids)
|
||||
|
||||
|
||||
def member_memory(container, detached=None) -> list[MemberMemory]:
|
||||
"""Read live RSS only for the container's Job, token or measured tree.
|
||||
|
||||
``detached`` reuses the last token scan; the measured tree is re-read every sample.
|
||||
"""
|
||||
if pl.IS_WINDOWS:
|
||||
import psutil
|
||||
|
||||
if container._job is None:
|
||||
return []
|
||||
members = []
|
||||
for proc in psutil.process_iter(["pid", "name", "memory_info"]):
|
||||
try:
|
||||
if _in_job(proc.pid, container._job):
|
||||
info = proc.info["memory_info"]
|
||||
if info is None:
|
||||
raise RuntimeError(f"RSS unavailable for Job member {proc.pid}")
|
||||
members.append(MemberMemory(proc.pid, info.rss, proc.info["name"] or "unknown"))
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
return members
|
||||
|
||||
owned = set(token_members(container) if detached is None else detached)
|
||||
if container._birth and pl.process_start_time(container._root) == container._birth:
|
||||
owned.update([container._root, *pl.collect_descendant_pids(container._root)])
|
||||
owned.discard(os.getpid())
|
||||
if os.path.isdir("/proc"): # Linux: slim images ship no ps; the kernel table is authoritative
|
||||
page, members = os.sysconf("SC_PAGE_SIZE"), []
|
||||
for pid in owned:
|
||||
try:
|
||||
resident = int(Path(f"/proc/{pid}/statm").read_text(encoding="ascii").split()[1])
|
||||
name = Path(f"/proc/{pid}/comm").read_text(encoding="utf-8", errors="replace").strip()
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue # exited between enumeration and the read
|
||||
members.append(MemberMemory(pid, resident * page, name))
|
||||
return members
|
||||
# One ps RSS table per sample; psutil is intentionally a Windows-only dependency.
|
||||
result = subprocess.run(["ps", "-A", "-o", "pid=,rss=,comm="],
|
||||
capture_output=True, text=True, timeout=5, check=True)
|
||||
members = []
|
||||
for line in result.stdout.splitlines():
|
||||
fields = line.split(maxsplit=2)
|
||||
if len(fields) == 3 and int(fields[0]) in owned:
|
||||
members.append(MemberMemory(int(fields[0]), int(fields[1]) * 1024, fields[2]))
|
||||
return members
|
||||
|
||||
|
||||
def stop_reason(total: int, memory, *, limit: int, floor: int, reserve: int) -> str:
|
||||
"""Why an owned tree must stop now, or "".
|
||||
|
||||
A tree past its limit always stops. Under the machine's physical reserve only
|
||||
a tree that outgrew what admission gave it stops: a normal run is never
|
||||
killed for another program's memory pressure.
|
||||
"""
|
||||
if total > limit:
|
||||
return "tree over its physical budget"
|
||||
if memory is not None and memory.available_bytes < reserve and total > floor:
|
||||
return "machine under its reserve and tree past its admitted size"
|
||||
return ""
|
||||
|
||||
|
||||
class OverloadWatch:
|
||||
"""A joined owner thread; a stop always survives as a nonempty reap error."""
|
||||
|
||||
def __init__(self, container, slots: int = 1):
|
||||
from ouroboros.config import TEST_PROCESS_SAMPLE_SEC, TEST_WORKER_PEAK_RSS_BYTES
|
||||
from ouroboros.test_parallelism import memory_budget, physical_memory
|
||||
|
||||
initial = physical_memory()
|
||||
# The admitted workers and their controller always fit, so admission never
|
||||
# starts a tree that this watch would then kill. Unknown memory leaves the floor.
|
||||
self.floor_bytes = (max(1, slots) + 1) * TEST_WORKER_PEAK_RSS_BYTES
|
||||
self.limit_bytes = max(memory_budget(initial) if initial else 0, self.floor_bytes)
|
||||
self.degraded = ""
|
||||
self._container = container
|
||||
self._interval = TEST_PROCESS_SAMPLE_SEC
|
||||
self._done = threading.Event()
|
||||
self._thread = None
|
||||
self.diagnostic = ""
|
||||
|
||||
def start(self) -> None:
|
||||
thread = threading.Thread(target=self._run, name="test-overload-watch", daemon=True)
|
||||
thread.start()
|
||||
self._thread = thread # A failed start must never leave an unjoinable thread.
|
||||
|
||||
def _run(self) -> None:
|
||||
from ouroboros.config import TEST_MEMORY_RESERVE_FRACTION, TEST_PROCESS_TOKEN_SCAN_SEC
|
||||
from ouroboros.test_parallelism import physical_memory
|
||||
|
||||
detached, scanned = None, None
|
||||
while not self._done.is_set():
|
||||
try:
|
||||
if not pl.IS_WINDOWS and (scanned is None or time.monotonic() - scanned >= TEST_PROCESS_TOKEN_SCAN_SEC):
|
||||
detached, scanned = token_members(self._container), time.monotonic()
|
||||
members = member_memory(self._container, detached)
|
||||
memory = physical_memory()
|
||||
except Exception as exc:
|
||||
# A sample this host cannot take is skipped (said once); it never fails a
|
||||
# healthy run or ends the watch. The tree is still owned and reaped.
|
||||
if not self.degraded:
|
||||
self.degraded = f"overload watch sample failed, the memory stop skips it: {exc}"
|
||||
print(self.degraded, file=sys.stderr, flush=True)
|
||||
else:
|
||||
total = sum(member.rss_bytes for member in members)
|
||||
reserve = int(memory.total_bytes * TEST_MEMORY_RESERVE_FRACTION) if memory else 0
|
||||
why = stop_reason(total, memory, limit=self.limit_bytes, floor=self.floor_bytes, reserve=reserve)
|
||||
if members and why:
|
||||
largest = max(members, key=lambda member: member.rss_bytes)
|
||||
self.diagnostic = (
|
||||
f"overload_stop: {why}; tree_rss={total} limit={self.limit_bytes} floor={self.floor_bytes} "
|
||||
f"available={memory.available_bytes if memory else 'unknown'} reserve={reserve}; "
|
||||
f"largest pid={largest.pid} name={largest.name!r} rss={largest.rss_bytes}"
|
||||
)
|
||||
try:
|
||||
self._container.stop_tree()
|
||||
except Exception as exc:
|
||||
self.diagnostic += f"; tree stop unconfirmed: {exc}"
|
||||
return
|
||||
if self._done.wait(self._interval):
|
||||
return
|
||||
|
||||
def stop(self) -> str:
|
||||
self._done.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join() # all process-table/memory probes have bounded waits
|
||||
return self.diagnostic
|
||||
|
|
@ -299,5 +299,5 @@ BYTE_BASELINE_DEBT = {
|
|||
}
|
||||
|
||||
BYTE_DEBT = {
|
||||
"tests/test_devtools_benchmarks.py": 326504,
|
||||
"tests/test_devtools_benchmarks.py": 324631,
|
||||
}
|
||||
|
|
|
|||
159
ouroboros/test_parallelism.py
Normal file
159
ouroboros/test_parallelism.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""Physical admission budget shared by the test entrypoints and nested runners.
|
||||
|
||||
This bounds initial fan-out, not later allocation: the process owner separately
|
||||
watches its tree for overload. A failed memory probe admits one worker.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Mapping, Optional, Sequence
|
||||
|
||||
# Transport state must survive the OUROBOROS_ runtime-settings scrub, just like
|
||||
# ProcessContainer's membership token. A test explicitly allocates extra slots
|
||||
# to a child by supplying this key in that child's environment.
|
||||
NESTED_WORKERS_ENV = "OURO_TEST_WORKER_BUDGET"
|
||||
WORKERS_ENV = "OUROBOROS_PREFLIGHT_TEST_WORKERS"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemorySnapshot:
|
||||
total_bytes: int
|
||||
available_bytes: int
|
||||
|
||||
|
||||
def _native_output(argv: Sequence[str]) -> str:
|
||||
# Bounded, same-call OS probes; no test process or daemon is started here.
|
||||
return subprocess.run(argv, check=True, capture_output=True, text=True,
|
||||
encoding="utf-8", timeout=2).stdout
|
||||
|
||||
|
||||
def physical_memory() -> Optional[MemorySnapshot]:
|
||||
"""Read total and reclaimable physical memory using native OS facilities."""
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import psutil # The packaged Windows runtime includes psutil.
|
||||
memory = psutil.virtual_memory()
|
||||
total, available = memory.total, memory.available
|
||||
elif sys.platform == "darwin":
|
||||
total = int(_native_output(["/usr/sbin/sysctl", "-n", "hw.memsize"]).strip())
|
||||
vm = _native_output(["/usr/bin/vm_stat"])
|
||||
page_size = int(re.search(r"page size of (\d+) bytes", vm).group(1))
|
||||
pages = {name.strip(): int(value.strip().rstrip("."))
|
||||
for name, value in (line.split(":", 1) for line in vm.splitlines()[1:] if ":" in line)}
|
||||
# Inactive/file-backed memory is reclaimable. Purgeable is already
|
||||
# included in active/inactive, so counting it again overstates room.
|
||||
available = page_size * sum(pages[name] for name in
|
||||
("Pages free", "Pages inactive", "Pages speculative"))
|
||||
elif sys.platform.startswith("linux"):
|
||||
fields = {name: int(value.split()[0]) * 1024 for name, value in
|
||||
(line.split(":", 1) for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines())}
|
||||
total, available = fields["MemTotal"], fields["MemAvailable"]
|
||||
# Inside a container, its cgroup v2 limit is the physical room, not the host's RAM;
|
||||
# reclaimable file cache counts as room, as MemAvailable counts it on the host.
|
||||
limit = Path("/sys/fs/cgroup/memory.max")
|
||||
if limit.is_file() and (cap := limit.read_text(encoding="ascii").strip()) != "max":
|
||||
used = int(Path("/sys/fs/cgroup/memory.current").read_text(encoding="ascii"))
|
||||
try:
|
||||
stat = dict(line.split() for line in Path("/sys/fs/cgroup/memory.stat").read_text(
|
||||
encoding="ascii").splitlines() if len(line.split()) == 2)
|
||||
except OSError:
|
||||
stat = {}
|
||||
used = max(0, used - int(stat.get("inactive_file", 0)))
|
||||
total, available = min(total, int(cap)), min(available, max(0, int(cap) - used))
|
||||
else:
|
||||
return None
|
||||
if total <= 0 or available < 0:
|
||||
return None
|
||||
return MemorySnapshot(total, min(total, available))
|
||||
except (OSError, ValueError, KeyError, AttributeError, ImportError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def memory_budget(memory: MemorySnapshot) -> int:
|
||||
"""Leave the configured physical reserve outside the admitted test tree."""
|
||||
from ouroboros.config import TEST_MEMORY_RESERVE_FRACTION
|
||||
|
||||
return max(0, memory.available_bytes - int(memory.total_bytes * TEST_MEMORY_RESERVE_FRACTION))
|
||||
|
||||
|
||||
def _positive(value: object, default: int) -> int:
|
||||
try:
|
||||
return max(1, int(str(value).strip()))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def nested_workers(env: Mapping[str, str]) -> int:
|
||||
"""A child has one slot unless its test explicitly allocates more."""
|
||||
return _positive(env.get(NESTED_WORKERS_ENV, "1"), 1)
|
||||
|
||||
|
||||
def requested_workers(cpu_workers: Optional[int] = None, *, env: Optional[Mapping[str, str]] = None) -> int:
|
||||
"""The deterministic request: the override or CPU default within parent slots."""
|
||||
env = os.environ if env is None else env
|
||||
requested = _positive(env.get(WORKERS_ENV, ""), cpu_workers or os.cpu_count() or 1)
|
||||
if NESTED_WORKERS_ENV in env:
|
||||
requested = min(requested, nested_workers(env))
|
||||
return max(1, requested)
|
||||
|
||||
|
||||
def worker_budget(cpu_workers: Optional[int] = None, *, env: Optional[Mapping[str, str]] = None) -> int:
|
||||
"""Keep the caller's CPU default, capped by physical room and parent slots."""
|
||||
from ouroboros.config import TEST_WORKER_PEAK_RSS_BYTES
|
||||
|
||||
memory = physical_memory()
|
||||
capacity = max(1, memory_budget(memory) // TEST_WORKER_PEAK_RSS_BYTES) if memory else 1
|
||||
return max(1, min(requested_workers(cpu_workers, env=env), capacity))
|
||||
|
||||
|
||||
def _cap_requested(value, slots: int, *, pytest_count: bool = False) -> str:
|
||||
text = str(value)
|
||||
if pytest_count and text in {"auto", "logical"}:
|
||||
return str(slots)
|
||||
try:
|
||||
count = int(text)
|
||||
except ValueError:
|
||||
return text # Preserve the native CLI's refusal of invalid input.
|
||||
return str(min(slots, count)) if count > 0 else text
|
||||
|
||||
|
||||
def bounded_test_command(command, env: Mapping[str, str]):
|
||||
"""Apply the child allocation to recognizable Node and pytest argv lists.
|
||||
|
||||
Popen's shared test seam covers legacy helpers too. Shell strings remain
|
||||
untouched; their owners must use argv or a runner reading the budget key.
|
||||
"""
|
||||
if not isinstance(command, (list, tuple)) or not command:
|
||||
return command
|
||||
args = list(command)
|
||||
executable = Path(os.fsdecode(args[0])).name.lower()
|
||||
slots = nested_workers(env)
|
||||
if executable in {"node", "node.exe"} and "--test" in args:
|
||||
option = "--test-concurrency"
|
||||
supplied = False
|
||||
for index, arg in enumerate(args):
|
||||
if arg == option:
|
||||
supplied = True
|
||||
if index + 1 < len(args):
|
||||
args[index + 1] = _cap_requested(args[index + 1], slots)
|
||||
if isinstance(arg, str) and arg.startswith(option + "="):
|
||||
supplied = True
|
||||
args[index] = option + "=" + _cap_requested(arg.split("=", 1)[1], slots)
|
||||
if not supplied:
|
||||
args.insert(args.index("--test") + 1, f"{option}={slots}")
|
||||
elif (executable.startswith("pytest") or
|
||||
any(args[i:i + 2] == ["-m", "pytest"] for i in range(len(args) - 1))):
|
||||
for index, arg in enumerate(args):
|
||||
if arg in {"-n", "--numprocesses"} and index + 1 < len(args):
|
||||
args[index + 1] = _cap_requested(args[index + 1], slots, pytest_count=True)
|
||||
elif isinstance(arg, str) and (arg.startswith("--numprocesses=") or
|
||||
arg.startswith("-n") and len(arg) > 2):
|
||||
value = arg.split("=", 1)[1] if "=" in arg else arg[2:]
|
||||
prefix = "--numprocesses=" if arg.startswith("--") else "-n"
|
||||
args[index] = prefix + _cap_requested(value, slots, pytest_count=True)
|
||||
return args
|
||||
277
ouroboros/test_selection.py
Normal file
277
ouroboros/test_selection.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
"""Commit/PR test scope: a contract floor, direct imports and authored widening.
|
||||
|
||||
Selection is computed from the candidate's files, including dirty and untracked
|
||||
inputs. It is a minimum, not a claim about dynamic or indirect consumers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
# Minimal witnesses of the contracts verification and every install rely on.
|
||||
# Replacing a witness is a verifier change and requires full qualification.
|
||||
# The whole Node lane is unconditional in both runners, outside this Python list.
|
||||
FLOOR_TESTS = {
|
||||
"tests/test_contracts.py": "frozen ABI and tool/context protocols",
|
||||
"tests/test_gateway_parity.py": "HTTP/WS wire schemas and router parity",
|
||||
"tests/test_packaging_sync.py": "release identity and public install carriers",
|
||||
"tests/test_process_custody.py": "every long-lived child has a lifetime owner",
|
||||
"tests/test_hermetic_data_root.py": "tests cannot bind the live data roots",
|
||||
"tests/test_preflight_process_containment.py": "a leaked test process cannot pass the gate",
|
||||
"tests/test_smoke.py": "the shipped Python modules import",
|
||||
"tests/test_safety_policy.py": "every built-in tool has an explicit safety policy",
|
||||
}
|
||||
|
||||
SELECTION_RULE = "floor + changed tests + direct importers + authored widening"
|
||||
# Bind the policy this process loaded, even if its source changes later on disk.
|
||||
_POLICY_SOURCE = Path(__file__).read_bytes()
|
||||
_APPARATUS = frozenset({
|
||||
"pyproject.toml", "pytest.ini", ".pytest.ini", "setup.cfg", "tox.ini",
|
||||
"uv.lock", "requirements.txt", "requirements-runtime.lock",
|
||||
"web/package.json", "web/package-lock.json", "site/pnpm-lock.yaml",
|
||||
"ouroboros/preflight_runner.py", "ouroboros/preflight_node.py",
|
||||
"ouroboros/preflight_receipts.py", "ouroboros/commit_admission.py",
|
||||
"ouroboros/test_selection.py", "ouroboros/preflight_inventory.py",
|
||||
"ouroboros/preflight_qualification.py", "scripts/run_tests.py", "scripts/safe_test.py",
|
||||
"ouroboros/test_environment.py", ".github/workflows/ci.yml",
|
||||
"ouroboros/process_containment.py", "ouroboros/process_lifeline.py",
|
||||
"ouroboros/process_overload.py", "ouroboros/test_parallelism.py",
|
||||
})
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> bytes:
|
||||
# Git inspection is bounded transport, never a test-runner timeout.
|
||||
return subprocess.run(["git", *args], cwd=repo, capture_output=True,
|
||||
check=True, timeout=30).stdout
|
||||
|
||||
|
||||
def changed_paths(repo: Path, base: str) -> tuple[str, tuple[str, ...]]:
|
||||
"""Resolve once; --no-renames keeps both sides of a module rename in scope."""
|
||||
oid = _git(repo, "rev-parse", "--verify", "--end-of-options", f"{base}^{{commit}}").decode().strip()
|
||||
raw = _git(repo, "diff", "--no-ext-diff", "--no-textconv", "--no-renames", "--name-only", "-z", oid, "--")
|
||||
raw += _git(repo, "ls-files", "--others", "--exclude-standard", "-z")
|
||||
return oid, tuple(sorted({os.fsdecode(p) for p in raw.split(b"\0") if p}))
|
||||
|
||||
|
||||
def is_test_module(path: str) -> bool:
|
||||
p = PurePosixPath(path)
|
||||
return bool(p.parts) and p.parts[0] == "tests" and p.name.startswith("test_") and p.suffix == ".py"
|
||||
|
||||
|
||||
def apparatus_change(path: str) -> bool:
|
||||
p = PurePosixPath(path)
|
||||
# Under tests/, everything but a test module (helpers, conftest, fixture data) is shared apparatus.
|
||||
return (path in _APPARATUS or path in FLOOR_TESTS
|
||||
or (p.parts[0] == "tests" and not is_test_module(path))
|
||||
or (p.name.startswith("requirements") and p.suffix in {".txt", ".lock"}))
|
||||
|
||||
|
||||
# Release carriers bumped by every numbered release (P9): only a change beyond the
|
||||
# release version makes them apparatus, or selection would never apply to a release.
|
||||
_RELEASE_CARRIERS = frozenset({"pyproject.toml", "uv.lock", "web/package.json", "web/package-lock.json"})
|
||||
_PYPROJECT_VERSION = re.compile(r'^version\s*=\s*"([^"]+)"', re.M)
|
||||
|
||||
|
||||
def _release_versions(repo: Path, base: str) -> set[str]:
|
||||
"""Both spellings of the base and candidate release version (VERSION and its PEP 440 form)."""
|
||||
versions = set()
|
||||
for name in ("VERSION", "pyproject.toml"):
|
||||
texts = []
|
||||
try:
|
||||
texts.append(_git(repo, "show", f"{base}:{name}").decode("utf-8"))
|
||||
except (subprocess.SubprocessError, UnicodeError):
|
||||
pass
|
||||
try:
|
||||
texts.append((repo / name).read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
pass
|
||||
for text in texts:
|
||||
if name == "VERSION":
|
||||
versions.add(text.strip())
|
||||
elif match := _PYPROJECT_VERSION.search(text):
|
||||
versions.add(match.group(1))
|
||||
return {version for version in versions if version}
|
||||
|
||||
|
||||
def _version_only_change(repo: Path, base: str, path: str, versions: set[str]) -> bool:
|
||||
try:
|
||||
old = _git(repo, "show", f"{base}:{path}").decode("utf-8")
|
||||
new = (repo / path).read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError, subprocess.SubprocessError):
|
||||
return False
|
||||
for version in sorted(versions, key=len, reverse=True):
|
||||
old, new = old.replace(version, "\0"), new.replace(version, "\0")
|
||||
return bool(versions) and old == new
|
||||
|
||||
|
||||
def _module(path: str) -> str:
|
||||
parts = list(PurePosixPath(path).with_suffix("").parts)
|
||||
if parts[-1] == "__init__":
|
||||
parts.pop()
|
||||
return ".".join(parts)
|
||||
|
||||
|
||||
def direct_imports(source: str, path: str) -> set[str]:
|
||||
"""Include imports in functions/TYPE_CHECKING; do not follow transitive edges."""
|
||||
imports: set[str] = set()
|
||||
package = path.removesuffix(".py").split("/")[:-1]
|
||||
for node in ast.walk(ast.parse(source, filename=path)):
|
||||
if isinstance(node, ast.Import):
|
||||
imports.update(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
prefix = ".".join(package[:len(package) - node.level + 1]) if node.level else ""
|
||||
module = ".".join(part for part in (prefix, node.module or "") if part)
|
||||
if module:
|
||||
imports.add(module)
|
||||
imports.update(f"{module}.{alias.name}" for alias in node.names if alias.name != "*")
|
||||
return imports
|
||||
|
||||
|
||||
# Every proof check re-selects; parsing ~2,000 test modules each time cost seconds per commit.
|
||||
# Keyed by content, so an edited module is parsed again; the bound only caps memory.
|
||||
_IMPORT_SCANS: dict[tuple[str, bytes], frozenset[str]] = {}
|
||||
_IMPORT_SCAN_ENTRIES = 50_000
|
||||
|
||||
|
||||
def _scanned_imports(repo: Path, path: str) -> frozenset[str]:
|
||||
source = (repo / path).read_text(encoding="utf-8")
|
||||
key = (path, hashlib.blake2b(source.encode("utf-8"), digest_size=16).digest())
|
||||
if key not in _IMPORT_SCANS:
|
||||
if len(_IMPORT_SCANS) >= _IMPORT_SCAN_ENTRIES:
|
||||
_IMPORT_SCANS.clear()
|
||||
_IMPORT_SCANS[key] = frozenset(direct_imports(source, path))
|
||||
return _IMPORT_SCANS[key]
|
||||
|
||||
|
||||
def validate_widening(repo: Path, extra_tests=(), full_tests=False, reason="") -> tuple[str, ...]:
|
||||
if type(full_tests) is not bool or not isinstance(extra_tests, (list, tuple)):
|
||||
raise ValueError("extra_tests must be a list of paths and full_tests must be a boolean")
|
||||
if not isinstance(reason, str):
|
||||
raise ValueError("test_reason must be a string")
|
||||
if (extra_tests or full_tests) and not reason.strip():
|
||||
raise ValueError("test_reason is required when widening with extra_tests or full_tests")
|
||||
normalized = set()
|
||||
for path in extra_tests:
|
||||
if not isinstance(path, str) or "\\" in path or ".." in PurePosixPath(path).parts:
|
||||
raise ValueError(f"extra_tests needs repository-relative test modules: {path!r}")
|
||||
p = PurePosixPath(path)
|
||||
if p.is_absolute() or not is_test_module(path) or not (repo / path).is_file():
|
||||
raise ValueError(f"extra_tests module is missing or invalid: {path!r}")
|
||||
if not (repo / path).resolve().is_relative_to(repo.resolve()):
|
||||
raise ValueError(f"extra_tests module escapes the candidate: {path!r}")
|
||||
normalized.add(p.as_posix())
|
||||
return tuple(sorted(normalized))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestSelection:
|
||||
__test__ = False
|
||||
tier: str
|
||||
base: str
|
||||
files: tuple[str, ...]
|
||||
reasons: tuple[tuple[str, str], ...]
|
||||
changed: tuple[str, ...]
|
||||
policy: str
|
||||
available: int
|
||||
extra_tests: tuple[str, ...] = ()
|
||||
full_tests: bool = False
|
||||
test_reason: str = ""
|
||||
|
||||
@property
|
||||
def targets(self) -> tuple[str, ...]:
|
||||
return ("tests/",) if self.tier == "full" else self.files
|
||||
|
||||
def record(self) -> dict:
|
||||
record = {**asdict(self), "rule": SELECTION_RULE, "omitted_files": self.available - len(self.files)}
|
||||
# A full run would list every module; its count and the rule say the same far smaller.
|
||||
record["files"] = record["expected_files"] = list(self.files) if self.tier == "selected" else []
|
||||
record["file_count"] = len(self.files)
|
||||
return record
|
||||
|
||||
|
||||
def select_from_paths(repo: Path, paths: Iterable[str], *, base: str,
|
||||
extra_tests=(), full_tests=False, test_reason="") -> TestSelection:
|
||||
"""Pure candidate scan seam, also used for historical shadow comparisons."""
|
||||
repo = Path(repo)
|
||||
extra = validate_widening(repo, extra_tests, full_tests, test_reason)
|
||||
missing = [path for path in FLOOR_TESTS if not (repo / path).is_file()]
|
||||
if missing:
|
||||
raise ValueError("required gate floor is missing: " + ", ".join(missing))
|
||||
changed = tuple(sorted(set(paths)))
|
||||
files = tuple(sorted(p.relative_to(repo).as_posix() for p in (repo / "tests").rglob("test_*.py")))
|
||||
reasons = [(path, f"floor: {invariant}") for path, invariant in FLOOR_TESTS.items()]
|
||||
versions = _release_versions(repo, base) if _RELEASE_CARRIERS & set(changed) else set()
|
||||
full = [(p, "verification apparatus changed") for p in changed if apparatus_change(p)
|
||||
and not (p in _RELEASE_CARRIERS and _version_only_change(repo, base, p, versions))]
|
||||
if full_tests:
|
||||
full.append(("*", "author requested full: " + test_reason.strip()))
|
||||
selected = set(FLOOR_TESTS)
|
||||
modules = {_module(p) for p in changed if p.endswith(".py") and not p.startswith("tests/")}
|
||||
for path in files:
|
||||
if path in changed:
|
||||
selected.add(path)
|
||||
reasons.append((path, "changed test module"))
|
||||
if not full and modules:
|
||||
try:
|
||||
imports = _scanned_imports(repo, path)
|
||||
except (SyntaxError, UnicodeError) as exc:
|
||||
# An unavailable graph grants no extra automatic escalation rule.
|
||||
raise ValueError(f"import scan unavailable for {path}; repair it or request full_tests with test_reason") from exc
|
||||
bound = sorted(name for name in modules if any(i == name or i.startswith(name + ".") for i in imports))
|
||||
if bound:
|
||||
selected.add(path)
|
||||
reasons.append((path, "direct import: " + ", ".join(bound)))
|
||||
reasons.extend((p, "author widening: " + test_reason.strip()) for p in extra)
|
||||
selected.update(extra)
|
||||
# Source digest binds policy changes even before someone updates a version label.
|
||||
policy = hashlib.sha256(_POLICY_SOURCE + json.dumps(FLOOR_TESTS, sort_keys=True).encode()).hexdigest()
|
||||
return TestSelection("full" if full else "selected", base, files if full else tuple(sorted(selected)),
|
||||
tuple(sorted(reasons + full)), changed, policy, len(files), extra, full_tests, test_reason.strip())
|
||||
|
||||
|
||||
def select_tests(repo: Path, *, base="HEAD", extra_tests=(), full_tests=False, test_reason="") -> TestSelection:
|
||||
oid, paths = changed_paths(Path(repo), base)
|
||||
return select_from_paths(Path(repo), paths, base=oid, extra_tests=extra_tests,
|
||||
full_tests=full_tests, test_reason=test_reason)
|
||||
|
||||
|
||||
def refresh_selection(repo: Path, selection: TestSelection) -> TestSelection:
|
||||
return select_tests(repo, base=selection.base, extra_tests=selection.extra_tests,
|
||||
full_tests=selection.full_tests, test_reason=selection.test_reason)
|
||||
|
||||
|
||||
def widening_properties() -> dict:
|
||||
"""Tool-schema SSOT: the model chooses consumers the import graph cannot see."""
|
||||
return {
|
||||
"extra_tests": {"type": "array", "items": {"type": "string"}, "default": [],
|
||||
"description": "Add test module paths for indirect/dynamic, prompt, skill or cross-language consumers beyond the floor and direct imports; explain in test_reason."},
|
||||
"full_tests": {"type": "boolean", "default": False,
|
||||
"description": "Request the full battery when impact is unresolved or broad qualification is needed; explain in test_reason. Adoption, managed updates and evolution publication require full regardless."},
|
||||
"test_reason": {"type": "string", "default": "",
|
||||
"description": "Why extra_tests or full_tests widens verification; retained with the commit's test evidence."},
|
||||
}
|
||||
|
||||
|
||||
def bind_commit_test_request(ctx, kwargs) -> str:
|
||||
request = {key: kwargs.pop(key, default) for key, default in
|
||||
(("extra_tests", []), ("full_tests", False), ("test_reason", ""))}
|
||||
try:
|
||||
request["extra_tests"] = validate_widening(Path(ctx.repo_dir), **{
|
||||
"extra_tests": request["extra_tests"], "full_tests": request["full_tests"], "reason": request["test_reason"]})
|
||||
except ValueError as exc:
|
||||
return f"⚠️ TOOL_ARG_ERROR: {exc}. Nothing was staged or reviewed."
|
||||
ctx._commit_test_request = request
|
||||
return ""
|
||||
|
||||
|
||||
def commit_test_options(ctx, *, force=False) -> dict:
|
||||
request = getattr(ctx, "_commit_test_request", None)
|
||||
return {"selected": True, **request} if request is not None and not force else {}
|
||||
|
|
@ -1343,6 +1343,7 @@ def _review_ledger_facts(ctx: ToolContext, commit_message: str, *, goal: str, sc
|
|||
"critical_findings": list(combined_findings or getattr(ctx, "_last_review_critical_findings", []) or []),
|
||||
"advisory_findings": list(getattr(ctx, "_last_review_advisory_findings", []) or []),
|
||||
"tests": _review_tests_facts(ctx, tests_passed),
|
||||
"test_request": dict(getattr(ctx, "_commit_test_request", None) or {}),
|
||||
"preflight": _review_preflight_facts(ctx),
|
||||
# The exhibit frozen before this attempt's dispatch, never re-derived at settle.
|
||||
"preflight_execution": dict(getattr(ctx, "_preflight_execution", None) or {"status": "unobserved"}),
|
||||
|
|
@ -1362,19 +1363,16 @@ def name_review_record(ctx: ToolContext, result: Any) -> Any:
|
|||
|
||||
|
||||
def _review_tests_facts(ctx: ToolContext, tests_passed: Any) -> Dict[str, Any]:
|
||||
"""``passed`` only for THIS candidate: the runner's flag is process state that outlives
|
||||
the checkout it tested, so it counts only when the process-held test proof still covers
|
||||
the current tree/index/workload (``commit_admission.PreflightTestProof``); a skipped
|
||||
or stale run is ``NOT_RUN`` with its reason, never a passed result borrowed from an
|
||||
earlier candidate. The exhibit this attempt froze for its seats governs too: one that
|
||||
never entered the tests (a deterministic refusal) is ``NOT_RUN`` although a kept
|
||||
earlier proof still covers the candidate."""
|
||||
"""Passed requires a proof covering this tree/index/workload, not merely a kept flag.
|
||||
Skipped/stale runs are NOT_RUN. The frozen exhibit governs too: deterministic
|
||||
refusal means tests never started, even when an older proof still covers the tree.
|
||||
"""
|
||||
if tests_passed is not True:
|
||||
return {"policy": "NOT_RUN", "result": "unknown"}
|
||||
try:
|
||||
from ouroboros.commit_admission import preflight_test_proof_matches
|
||||
|
||||
bound = bool(preflight_test_proof_matches(ctx, ctx.repo_dir))
|
||||
bound = bool(preflight_test_proof_matches(ctx, ctx.repo_dir, require_full=False))
|
||||
except Exception:
|
||||
bound = False
|
||||
exhibit = getattr(ctx, "_preflight_execution", None)
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ def self_change_tool_entries() -> list:
|
|||
"restart, after the running generation's readers have stopped."),
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"reason": {"type": "string"},
|
||||
"adopt_commit": {"type": "string", "description": "Exact reviewed candidate commit SHA to adopt at this restart."},
|
||||
"adopt_commit": {"type": "string", "description": "Exact reviewed candidate commit SHA to adopt at this restart; it needs a full green test run of that commit on record (commit it with full_tests=true)."},
|
||||
}, "required": ["reason"]},
|
||||
}, _request_restart),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||
|
||||
from ouroboros.config import get_runtime_mode # noqa: F401
|
||||
from ouroboros.tools.review_helpers import review_enforcement_blocks
|
||||
from ouroboros.test_selection import bind_commit_test_request, commit_test_options, widening_properties
|
||||
from ouroboros.runtime_mode_policy import (
|
||||
core_patch_notice, # noqa: F401
|
||||
format_protected_paths, # noqa: F401
|
||||
|
|
@ -643,10 +644,8 @@ def _run_pre_push_tests(ctx: ToolContext, force: bool = False) -> Optional[str]:
|
|||
return None
|
||||
if not force and os.environ.get("OUROBOROS_PRE_PUSH_TESTS", "1") != "1":
|
||||
return None
|
||||
# NO `tests/` existence check here: whether the repository is in scope is
|
||||
# run_hermetic_pytest's decision. This entry point runs POST-commit, so it
|
||||
# compares HEAD and HEAD~1 (the default phase) — a candidate that deleted the
|
||||
# suite is a hard block, while a repo that never had one is out of scope.
|
||||
# The runner checks HEAD and HEAD~1: deleting the suite blocks;
|
||||
# a repository that never had tests stays out of scope.
|
||||
try:
|
||||
from ouroboros.preflight_runner import run_hermetic_pytest
|
||||
|
||||
|
|
@ -654,7 +653,7 @@ def _run_pre_push_tests(ctx: ToolContext, force: bool = False) -> Optional[str]:
|
|||
return run_hermetic_pytest(
|
||||
pathlib.Path(ctx.repo_dir),
|
||||
max_output=MAX_TEST_OUTPUT,
|
||||
ctx=ctx,
|
||||
ctx=ctx, **commit_test_options(ctx, force=force),
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Pre-push tests failed with exception: {e}", exc_info=True)
|
||||
|
|
@ -1043,6 +1042,8 @@ def _publish_reviewed_commit(
|
|||
|
||||
def _commit_reviewed(ctx: ToolContext, commit_message: str, *args: Any, **kwargs: Any) -> str:
|
||||
"""The public commit handler: an outcome of a call that wrote a review record names it."""
|
||||
if error := bind_commit_test_request(ctx, kwargs):
|
||||
return _publish_tool_result(ctx, ToolResult(status="error", code="TOOL_ARG_ERROR", text=error))
|
||||
return name_review_record(ctx, _repo_commit_push(ctx, commit_message, *args, **kwargs))
|
||||
|
||||
|
||||
|
|
@ -1366,11 +1367,9 @@ def _repo_commit_push(ctx: ToolContext, commit_message: str,
|
|||
if receipt_error:
|
||||
return receipt_error
|
||||
if not _managed_tx and not publication_paused:
|
||||
# Ordinary self-modification contract: post-commit tests are reported
|
||||
# as a warning. Managed-update merges already ran them as the BLOCKING
|
||||
# assisted_post_commit_tests gate right after the commit above.
|
||||
# Evolution publication requires full even when the author skipped preflight.
|
||||
try:
|
||||
run_operation(ctx, _post_commit_result, ctx, commit_message, skip_tests, test_warning_ref)
|
||||
run_operation(ctx, _post_commit_result, ctx, commit_message, skip_tests, test_warning_ref, force=bool(evolution_claim))
|
||||
except OwnerPauseRefused as exc:
|
||||
publication_paused = True
|
||||
tag_info += f" [post-commit tests not started: owner pause ({exc})]"
|
||||
|
|
@ -1382,7 +1381,8 @@ def _repo_commit_push(ctx: ToolContext, commit_message: str,
|
|||
)
|
||||
if publication_error:
|
||||
return publication_error
|
||||
push_status = _auto_push_after_review(ctx, publication_paused=publication_paused) if not body_candidate.is_bound(ctx) else body_candidate.publication_note(ctx)
|
||||
push_status = (_evolution_test_publication_stop(ctx, commit_sha, test_warning_ref[0])
|
||||
or (_auto_push_after_review(ctx, publication_paused=publication_paused) if not body_candidate.is_bound(ctx) else body_candidate.publication_note(ctx)))
|
||||
ctx.last_reviewed_commit_sha = commit_sha
|
||||
body_candidate.record_reviewed_commit(ctx, commit_sha)
|
||||
if attribution_binding is not None:
|
||||
|
|
@ -1449,6 +1449,7 @@ def get_tools() -> List[ToolEntry]:
|
|||
"checks, the tests (see skip_tests) and the panel still run; the opposite of preflight_reviewer."
|
||||
)
|
||||
commit_properties = {
|
||||
**widening_properties(),
|
||||
"root": {"type": "string", "enum": ["system_repo"], "default": "system_repo", "description": "The reviewed commit lands in Ouroboros's own body only; review any other root with review_change and commit it with ordinary git."},
|
||||
"commit_message": {"type": "string"},
|
||||
"paths": {"type": "array", "items": {"type": "string"}, "description": "Optional subset of task-attributed paths. Omitted computes candidates; empty never stages the whole tree."},
|
||||
|
|
@ -1569,6 +1570,7 @@ from ouroboros.tools.git_evolution import ( # noqa: E402,F401
|
|||
_check_evolution_commit_stage,
|
||||
_evolution_commit_authority,
|
||||
_evolution_publication_stopped_result,
|
||||
_evolution_test_publication_stop,
|
||||
_preserve_evolution_orphan,
|
||||
_record_evolution_commit_intent,
|
||||
_record_evolution_commit_receipt,
|
||||
|
|
|
|||
|
|
@ -355,6 +355,23 @@ def _record_evolution_commit_receipt(
|
|||
return message
|
||||
|
||||
|
||||
def _evolution_test_publication_stop(ctx: ToolContext, commit_sha: str, test_warning: str) -> str:
|
||||
"""Withhold an evolution push that lacks full green proof of this exact commit.
|
||||
|
||||
Unlike lost authority this keeps the reviewed commit's provenance and settles the
|
||||
attempt; only publication waits, and adoption still refuses the commit without it.
|
||||
"""
|
||||
if str(ctx.current_task_type or "") != "evolution":
|
||||
return ""
|
||||
from ouroboros.preflight_qualification import full_proof_error
|
||||
|
||||
error = test_warning or full_proof_error(ctx, ctx.repo_dir, commit_sha)
|
||||
if not error:
|
||||
return ""
|
||||
ctx.last_push_succeeded = False
|
||||
return f" [EVOLUTION_PUBLICATION_STOPPED: {error.strip()}; the reviewed commit stays local, nothing was pushed]"
|
||||
|
||||
|
||||
def _evolution_publication_stopped_result(
|
||||
ctx: ToolContext, commit_message: str, commit_sha: str, test_warning: str,
|
||||
created_tag: str = "", started_at: float = 0.0,
|
||||
|
|
|
|||
|
|
@ -803,6 +803,8 @@ def _run_review_preflight_tests(ctx: "Any", timeout: Optional[int] = None, *, fo
|
|||
|
||||
# Pre-commit entry point: the deleted-suite baseline is HEAD alone.
|
||||
run_kwargs = {"max_output": MAX_OUTPUT, "phase": PRE_COMMIT_PHASE, "ctx": ctx}
|
||||
from ouroboros.test_selection import commit_test_options
|
||||
run_kwargs.update(commit_test_options(ctx, force=force))
|
||||
if timeout is not None:
|
||||
run_kwargs["timeout"] = timeout
|
||||
output = run_hermetic_pytest(pathlib.Path(repo_dir), **run_kwargs)
|
||||
|
|
|
|||
|
|
@ -259,10 +259,9 @@ account citing corrected direct sources, not by merely reselecting the old one.
|
|||
I read every reader and writer, update docs, prompts, and tests in the same
|
||||
diff, keep `docs/ARCHITECTURE.md` rationale in sync for non-obvious
|
||||
decisions, and run focused tests before review. `python scripts/run_tests.py`
|
||||
is the fast full battery (arguments forward a focused run); a bare
|
||||
`pytest tests/` is one slow process, and the reviewed commit runs the full
|
||||
battery itself or reuses a matching receipt unless tests are explicitly
|
||||
skipped.
|
||||
runs all tests; commits use floor + importers. I add `extra_tests`
|
||||
or `full_tests` with `test_reason` for indirect/dynamic or unresolved impact.
|
||||
Adoption, managed updates and evolution publication require full tests.
|
||||
- I preserve my own work: never delete or overwrite a viable result, candidate,
|
||||
or unique input without a recoverable copy; save a working deliverable as
|
||||
soon as I have one, then improve copies.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
|
||||
python scripts/run_tests.py full battery (node lane + every default-lane test)
|
||||
python scripts/run_tests.py --sequential full battery exactly as the commit gate and CI split it
|
||||
python scripts/run_tests.py --selected --base HEAD commit/PR floor + affected tests, with both lanes
|
||||
python scripts/run_tests.py --node only the complete Node lane (CI runs it as its own step)
|
||||
python scripts/run_tests.py --evidence-dir DIR pytest diagnostics in DIR/<phase>, passed by argv
|
||||
python scripts/run_tests.py tests/test_x.py focused run; every argument is forwarded to pytest
|
||||
|
||||
|
|
@ -21,11 +23,13 @@ isolation is `tests/conftest.py`'s job and is not repeated either.
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
|
@ -37,16 +41,15 @@ _PYTEST_EXIT_NO_TESTS = 5
|
|||
|
||||
|
||||
def _workers() -> int:
|
||||
"""What `-n auto` will resolve to: the operator override, else xdist's own count."""
|
||||
raw = os.environ.get("OUROBOROS_PREFLIGHT_TEST_WORKERS", "").strip()
|
||||
if raw.isdigit():
|
||||
return max(2, int(raw))
|
||||
"""Keep xdist's CPU default within the physical and inherited allocation."""
|
||||
from ouroboros.test_parallelism import worker_budget
|
||||
|
||||
try: # xdist prefers physical cores whenever psutil is importable
|
||||
import psutil
|
||||
physical = psutil.cpu_count(logical=False)
|
||||
except Exception:
|
||||
physical = None
|
||||
return max(2, physical or os.cpu_count() or 2)
|
||||
return worker_budget(physical or os.cpu_count() or 1)
|
||||
|
||||
|
||||
def serial_shards(workers: int) -> int:
|
||||
|
|
@ -82,14 +85,35 @@ def battery_commands(sequential: bool) -> list[tuple[str, list[str]]]:
|
|||
|
||||
def _run(label: str, argv: list[str], cwd: pathlib.Path) -> int:
|
||||
from ouroboros.preflight_node import node_environment
|
||||
from ouroboros.process_containment import ProcessContainer
|
||||
from ouroboros.test_parallelism import NESTED_WORKERS_ENV, bounded_test_command
|
||||
|
||||
print(f"\n=== {label}: {shlex.join(argv[2:] if argv[0] == sys.executable else argv)}", flush=True)
|
||||
started = time.monotonic()
|
||||
env = node_environment(os.environ)
|
||||
override = env.get("OUROBOROS_PREFLIGHT_TEST_WORKERS", "").strip()
|
||||
if override.isdigit(): # the gate steers `-n auto` the same way (preflight_runner._preflight_env)
|
||||
env["PYTEST_XDIST_AUTO_NUM_WORKERS"] = str(max(2, int(override)))
|
||||
code = subprocess.call(argv, cwd=str(cwd), env=env)
|
||||
env["PYTEST_XDIST_AUTO_NUM_WORKERS"] = str(_workers())
|
||||
env[NESTED_WORKERS_ENV] = env["PYTEST_XDIST_AUTO_NUM_WORKERS"]
|
||||
container = ProcessContainer(lifeline=True, overload=True)
|
||||
proc = None
|
||||
try:
|
||||
proc = container.spawn(bounded_test_command(argv, env), cwd=str(cwd), env=env)
|
||||
code = proc.wait()
|
||||
except KeyboardInterrupt:
|
||||
code = 130
|
||||
finally:
|
||||
try:
|
||||
diagnostic = container.reap()
|
||||
if proc is not None:
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
diagnostic = "; ".join(filter(None, [diagnostic, "test root did not exit after reap"]))
|
||||
if diagnostic:
|
||||
print(f"=== {label}: {diagnostic}", file=sys.stderr, flush=True)
|
||||
finally:
|
||||
container.close()
|
||||
if diagnostic and code in (0, _PYTEST_EXIT_NO_TESTS):
|
||||
code = 1
|
||||
print(f"=== {label}: exit {code} in {time.monotonic() - started:.0f}s", flush=True)
|
||||
return code
|
||||
|
||||
|
|
@ -110,12 +134,105 @@ def _node_lane() -> int:
|
|||
print(f"\n=== node: NOT_RUN — {found}; the web lane needs node >= {floor}, so this is not the full "
|
||||
"battery: install node or pass focused targets", flush=True)
|
||||
return 1
|
||||
return _run("node", [node, "--test", *files], REPO / WEB_DIR)
|
||||
return _run("node", [node, "--test", f"--test-concurrency={_workers()}", *files], REPO / WEB_DIR)
|
||||
|
||||
|
||||
def _selected(args, evidence) -> int:
|
||||
from ouroboros.preflight_inventory import inventory_problem, read_inventory, selection_passes
|
||||
from ouroboros.preflight_runner import _probe_dir, _install_worker_probe
|
||||
from ouroboros.test_selection import select_tests
|
||||
|
||||
try:
|
||||
selection = select_tests(REPO, base=args.base or "HEAD", extra_tests=args.extra_test,
|
||||
full_tests=args.full_tests, test_reason=args.test_reason)
|
||||
except (ValueError, OSError, subprocess.SubprocessError) as exc:
|
||||
print(f"selection failed: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"=== {selection.tier}: {len(selection.files)}/{selection.available} Python modules; "
|
||||
f"{selection.record()['rule']}; base {selection.base}; complete Node lane", flush=True)
|
||||
# Retained diagnostics; process ownership belongs to _run, as in the full CLI.
|
||||
root = evidence or pathlib.Path(tempfile.mkdtemp(prefix="ouroboros-selected-"))
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
record = selection.record()
|
||||
record["inventory"] = {}
|
||||
record["outcomes"] = {"parallel": "skipped", "serial": "skipped"}
|
||||
output = root / "selection.json"
|
||||
output.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"=== selection evidence: {output}", flush=True)
|
||||
if args.defer_full and selection.tier == "full":
|
||||
selection = _defer_full(selection)
|
||||
record = {**selection.record(), "inventory": {}, "outcomes": record["outcomes"], "deferred_full": True}
|
||||
output.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
|
||||
code = _node_lane()
|
||||
previous = os.environ.get("PYTHONPATH")
|
||||
completed = False
|
||||
os.environ["PYTHONPATH"] = os.pathsep.join(filter(None, (str(_probe_dir(root)), previous)))
|
||||
try:
|
||||
probe = _install_worker_probe(root)
|
||||
for spec in selection_passes(selection, probe_module=probe, temp_root=root) if code == 0 else []:
|
||||
command = [sys.executable, "-m", "pytest", *spec.args,
|
||||
"--ci-evidence-dir", str(root / spec.label)]
|
||||
code = _run(spec.label, command, REPO)
|
||||
inventory, problem = read_inventory(root, spec.label)
|
||||
record["inventory"][spec.label] = inventory
|
||||
record["outcomes"][spec.label] = "success" if code in (0, _PYTEST_EXIT_NO_TESTS) and not problem else "failure"
|
||||
output.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
|
||||
if code not in (0, _PYTEST_EXIT_NO_TESTS):
|
||||
break
|
||||
code = int(bool(problem))
|
||||
if problem:
|
||||
print(f"inventory failed: {problem}", file=sys.stderr)
|
||||
break
|
||||
if code == 0 and (problem := inventory_problem(selection, record["inventory"])):
|
||||
print(problem, file=sys.stderr)
|
||||
code = 1
|
||||
completed = True
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("PYTHONPATH", None)
|
||||
else:
|
||||
os.environ["PYTHONPATH"] = previous
|
||||
record["outcome"] = "success" if completed and code == 0 else "failure"
|
||||
output.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
|
||||
if destination := os.environ.get("GITHUB_OUTPUT"):
|
||||
with open(destination, "a", encoding="utf-8") as stream:
|
||||
for label, outcome in record["outcomes"].items():
|
||||
stream.write(f"{label}_outcome={outcome}\n")
|
||||
print(f"=== {selection.tier} battery: {'GREEN' if code == 0 else 'RED'}", flush=True)
|
||||
return code
|
||||
|
||||
|
||||
def _defer_full(selection):
|
||||
"""A full escalation this leg cannot finish (Windows runs full in four landed shards).
|
||||
|
||||
The leg still runs the PR minimum, the contract floor and the Node lane, and says
|
||||
what it left to the landed matrix.
|
||||
"""
|
||||
import dataclasses
|
||||
|
||||
from ouroboros.test_selection import FLOOR_TESTS
|
||||
|
||||
why = "; ".join(f"{path}: {reason}" for path, reason in selection.reasons
|
||||
if reason == "verification apparatus changed" or reason.startswith("author requested full"))
|
||||
note = f"full scope deferred to the landed matrix; this leg runs the contract floor and the Node lane ({why})"
|
||||
print(f"=== {note}", flush=True)
|
||||
if destination := os.environ.get("GITHUB_STEP_SUMMARY"):
|
||||
with open(destination, "a", encoding="utf-8") as stream:
|
||||
stream.write(f"**{note}**\n")
|
||||
return dataclasses.replace(selection, tier="selected", files=tuple(sorted(FLOOR_TESTS)))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--sequential", action="store_true", help="two passes, exactly as the commit gate and CI")
|
||||
parser.add_argument("--selected", action="store_true", help="floor + affected tests and complete Node lane")
|
||||
parser.add_argument("--base", help="Git base for --selected; defaults to HEAD")
|
||||
parser.add_argument("--extra-test", action="append", default=[], help="widen selection with a test module")
|
||||
parser.add_argument("--full-tests", action="store_true", help="widen --selected to the full battery")
|
||||
parser.add_argument("--test-reason", default="", help="reason for authored widening")
|
||||
parser.add_argument("--defer-full", action="store_true",
|
||||
help="with --selected: on a full escalation run only the floor and Node lane, and say so")
|
||||
parser.add_argument("--node", action="store_true", help="only the complete Node lane, owned like every pass")
|
||||
parser.add_argument("--evidence-dir", type=pathlib.Path,
|
||||
help="write pytest diagnostics under DIR/<phase>; never inherited by nested pytest",
|
||||
metavar="DIR")
|
||||
|
|
@ -124,6 +241,16 @@ def main(argv: list[str] | None = None) -> int:
|
|||
if not (REPO / "tests" / "conftest.py").is_file():
|
||||
print(f"{REPO} is not an Ouroboros checkout (no tests/conftest.py)", file=sys.stderr)
|
||||
return 2
|
||||
if args.node:
|
||||
if args.selected or args.sequential or pytest_args:
|
||||
parser.error("--node runs only the Node lane")
|
||||
return _node_lane()
|
||||
if args.selected:
|
||||
if pytest_args:
|
||||
parser.error("--selected cannot be combined with arbitrary pytest arguments")
|
||||
return _selected(args, evidence)
|
||||
if args.base or args.extra_test or args.full_tests or args.test_reason or args.defer_full:
|
||||
parser.error("--base, --defer-full and widening options require --selected")
|
||||
if pytest_args:
|
||||
if args.sequential:
|
||||
parser.error("--sequential selects the full two-pass battery; drop it or drop the pytest arguments")
|
||||
|
|
@ -131,6 +258,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||
pytest_args = ["--ci-evidence-dir", str(evidence / "focused"), *pytest_args]
|
||||
return _run("focused", [sys.executable, "-m", "pytest", *pytest_args], REPO)
|
||||
started = time.monotonic()
|
||||
print(f"=== full: {sum(1 for _ in (REPO / 'tests').rglob('test_*.py'))} Python modules; "
|
||||
"full default suite; complete Node lane", flush=True)
|
||||
code = _node_lane()
|
||||
ran = 0
|
||||
for label, command in battery_commands(args.sequential) if code == 0 else []:
|
||||
|
|
|
|||
|
|
@ -105,8 +105,16 @@ def make_serving(root: pathlib.Path) -> pathlib.Path:
|
|||
return repo
|
||||
|
||||
|
||||
def stub_adoption_evidence(monkeypatch) -> None:
|
||||
"""Miniature bodies exercise switching, not the battery; the real full-evidence
|
||||
acceptance/refusal boundary is exercised in test_preflight_selection.py."""
|
||||
monkeypatch.setattr("ouroboros.preflight_qualification.adoption_test_evidence",
|
||||
lambda *args, **kwargs: {"tier": "full", "source": "stub", "head": args[2]})
|
||||
|
||||
|
||||
def isolate(monkeypatch, root: pathlib.Path) -> pathlib.Path:
|
||||
"""Point the worktree registry and data root at ``root``; returns the data dir."""
|
||||
stub_adoption_evidence(monkeypatch)
|
||||
data = root / "data"
|
||||
(data / "state").mkdir(parents=True, exist_ok=True)
|
||||
(data / "logs").mkdir(parents=True, exist_ok=True)
|
||||
|
|
|
|||
|
|
@ -139,6 +139,14 @@ def render_summary(root: Path, *, producer_outcome: str, artifact_outcome: str,
|
|||
"""Reporter failure cannot replace producer truth; missing reports stay unknown."""
|
||||
data, error = _final(root)
|
||||
available = not error
|
||||
# The selected CLI writes scope beside the pass directories before execution.
|
||||
# Its actual tier may escalate to full; never describe a subset as full.
|
||||
try:
|
||||
selection = json.loads((Path(root).parent / "selection.json").read_text(encoding="utf-8"))
|
||||
if scope == "full" and selection.get("tier") in ("selected", "full"):
|
||||
scope = selection["tier"]
|
||||
except (OSError, ValueError, AttributeError):
|
||||
pass
|
||||
lines = [
|
||||
"## CI test evidence" + (f" — {_cell(label)}" if label else "")
|
||||
+ (" — PARTIAL DIAGNOSTIC" if scope != "full" else ""),
|
||||
|
|
@ -661,6 +669,9 @@ class _Results:
|
|||
lane = getattr(self.config, "_default_suite_lane", None)
|
||||
if lane:
|
||||
facts["default_" + lane["pass"]] = lane
|
||||
crash = getattr(self.config, "_ouroboros_worker_crash", None)
|
||||
if crash:
|
||||
facts["worker_crash"] = crash
|
||||
write_json(output_dir(self.config) / "results.json", _redact(facts, self.secrets))
|
||||
except Exception as error:
|
||||
# Diagnostics alone never alter session.exitstatus or expose a body.
|
||||
|
|
|
|||
|
|
@ -215,6 +215,9 @@ _PYTEST_POPEN_PATCHED = False
|
|||
|
||||
|
||||
def _isolated_child_env(value) -> dict:
|
||||
from ouroboros.process_containment import CONTAINMENT_ENV_PREFIX
|
||||
from ouroboros.test_parallelism import NESTED_WORKERS_ENV, nested_workers
|
||||
|
||||
child_env = dict(value)
|
||||
synthetic_home = (child_env.get("HOME") or child_env.get("USERPROFILE"))
|
||||
synthetic_home = synthetic_home and synthetic_home != _PYTEST_DEFAULTS["HOME"]
|
||||
|
|
@ -236,6 +239,12 @@ def _isolated_child_env(value) -> dict:
|
|||
child_env.pop(key, None)
|
||||
child_env["OUROBOROS_PYTEST_ACTIVE"] = "1"
|
||||
child_env["OUROBOROS_TEST_LIVE_DATA_ROOT"] = _PYTEST_CHILD_LIVE_ROOT
|
||||
# Custom env={} must retain every outer owner's attribution. Otherwise a
|
||||
# test child that changes its process group escapes the owner's token scan.
|
||||
child_env.update({key: value for key, value in os.environ.items()
|
||||
if key.startswith(CONTAINMENT_ENV_PREFIX)})
|
||||
child_env[NESTED_WORKERS_ENV] = str(nested_workers(child_env))
|
||||
child_env["PYTEST_XDIST_AUTO_NUM_WORKERS"] = child_env[NESTED_WORKERS_ENV]
|
||||
return child_env
|
||||
|
||||
|
||||
|
|
@ -247,11 +256,19 @@ def _install_pytest_child_isolation() -> None:
|
|||
|
||||
@functools.wraps(_ORIGINAL_POPEN_INIT)
|
||||
def isolated_init(self, *args, **kwargs):
|
||||
from ouroboros.test_parallelism import bounded_test_command
|
||||
|
||||
positional = list(args)
|
||||
if len(positional) > 10:
|
||||
positional[10] = _isolated_child_env(os.environ if positional[10] is None else positional[10])
|
||||
env = positional[10]
|
||||
else:
|
||||
kwargs["env"] = _isolated_child_env(os.environ if kwargs.get("env") is None else kwargs["env"])
|
||||
env = kwargs["env"]
|
||||
if positional:
|
||||
positional[0] = bounded_test_command(positional[0], env)
|
||||
elif "args" in kwargs:
|
||||
kwargs["args"] = bounded_test_command(kwargs["args"], env)
|
||||
return _ORIGINAL_POPEN_INIT(self, *positional, **kwargs)
|
||||
|
||||
subprocess.Popen.__init__ = isolated_init
|
||||
|
|
@ -464,6 +481,20 @@ def _pin_lane_groups(items, shards: int) -> None:
|
|||
items.sort(key=lambda item: item.get_closest_marker("serial") is None)
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True)
|
||||
def pytest_cmdline_main(config):
|
||||
from ouroboros.test_parallelism import NESTED_WORKERS_ENV, nested_workers
|
||||
|
||||
# Generated entry scripts can call pytest.main directly, bypassing the argv
|
||||
# seam. xdist resolves -n auto later in this hook; cap explicit counts too.
|
||||
count = getattr(config.option, "numprocesses", None)
|
||||
if count and NESTED_WORKERS_ENV in os.environ:
|
||||
slots = nested_workers(os.environ)
|
||||
config.option.numprocesses = min(count, slots) if isinstance(count, int) else slots
|
||||
# xdist's own tryfirst hook may already have expanded -n into gateways.
|
||||
config.option.tx = config.option.tx[:slots]
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True)
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "scale: storage operation-count acceptance measurements")
|
||||
|
|
@ -532,6 +563,12 @@ def _claim_fresh_basetemp(root: pathlib.Path) -> str:
|
|||
|
||||
|
||||
def pytest_sessionstart(session): # noqa: ARG001
|
||||
from ouroboros.test_parallelism import NESTED_WORKERS_ENV
|
||||
|
||||
session.config._ouroboros_session = session
|
||||
# This process already owns its allocation. Test-created runners begin with
|
||||
# one slot; tests of real concurrency opt in by setting their child's key.
|
||||
os.environ[NESTED_WORKERS_ENV] = "1"
|
||||
_bind_pytest_runtime_roots()
|
||||
_install_pytest_child_isolation()
|
||||
repo_root = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
|
@ -539,6 +576,9 @@ def pytest_sessionstart(session): # noqa: ARG001
|
|||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus): # noqa: ARG001
|
||||
crash = getattr(session.config, "_ouroboros_unowned_crash", None)
|
||||
if crash and not getattr(session.config, "_ouroboros_worker_crash", None):
|
||||
_record_worker_crash(*crash) # after the run, so "unfinished" names only what never finished
|
||||
# Under pytest-xdist this hook fires on the controller AND every worker process against the
|
||||
# SHARED repo root. Run the repo-root pollution sweep + exitstatus mutation ONLY on the
|
||||
# controller (the single authority): otherwise workers race the same shutil.rmtree and each
|
||||
|
|
@ -1085,6 +1125,24 @@ def _isolate_workspace_executor_globals():
|
|||
svc._SERVICES.update(saved_svc_services)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_supervisor_queue_refs():
|
||||
"""Undo a test's ``init_queue_refs`` rebinding of the supervisor queue's maps.
|
||||
|
||||
Fixtures bind private PENDING/RUNNING objects and rarely bind them back, so a
|
||||
later test in the same worker read the earlier one's queue (a scheduled task
|
||||
became an extra history read in an unrelated module).
|
||||
"""
|
||||
try:
|
||||
from supervisor import queue
|
||||
except Exception:
|
||||
yield
|
||||
return
|
||||
saved = (queue.PENDING, queue.RUNNING, queue.QUEUE_SEQ_COUNTER_REF)
|
||||
yield
|
||||
queue.PENDING, queue.RUNNING, queue.QUEUE_SEQ_COUNTER_REF = saved
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_repo_writer_gate():
|
||||
"""Reset the process-global repo-writer admission latch between tests.
|
||||
|
|
@ -1215,10 +1273,88 @@ def _fail_if_a_thread_leaked(item):
|
|||
f"test it pollutes", pytrace=False)
|
||||
|
||||
|
||||
@pytest.hookimpl(optionalhook=True)
|
||||
@pytest.hookimpl(optionalhook=True, trylast=True)
|
||||
def pytest_testnodedown(node, error): # noqa: ARG001
|
||||
# pytest-xdist controller: merge each worker's leak list (shipped via workeroutput below).
|
||||
_THREAD_LEAKS.extend(getattr(node, "workeroutput", {}).get("thread_leaks", []))
|
||||
if error is None:
|
||||
return
|
||||
session = getattr(node.config, "_ouroboros_session", None)
|
||||
reason = f"xdist worker {node.gateway.id} died: {error}"
|
||||
controller = node.config.pluginmanager.get_plugin("dsession")
|
||||
if not _owned_run():
|
||||
# Nobody outer reaps this run: xdist's own crash handling lets running tests finish
|
||||
# and clean up their children. The facts are recorded when the session ends.
|
||||
if controller is not None and not hasattr(node.config, "_ouroboros_unowned_crash"):
|
||||
node.config._ouroboros_unowned_crash = (node, reason, controller.sched)
|
||||
return
|
||||
if session is not None:
|
||||
session.shouldfail = reason
|
||||
if controller is not None:
|
||||
# xdist's loop reads its own shouldstop, not Session.shouldfail. Stop
|
||||
# scheduling before it redistributes the dead worker's pending items.
|
||||
controller.shouldstop = reason
|
||||
controller._max_worker_restart = 0
|
||||
controller.triggershutdown()
|
||||
scheduler = controller.sched
|
||||
# With no collected/assigned test xdist emits no crash report. Its
|
||||
# native schedulers expose precisely the work remove_node would use.
|
||||
pending = True
|
||||
if hasattr(scheduler, "assigned_work"):
|
||||
pending = any(not done for unit in scheduler.assigned_work.get(node, {}).values()
|
||||
for done in unit.values())
|
||||
elif hasattr(scheduler, "node2pending"):
|
||||
pending = bool(scheduler.node2pending.get(node))
|
||||
if getattr(scheduler, "collection", None) is None or not pending:
|
||||
_record_worker_crash(node, reason, scheduler)
|
||||
pytest.exit(reason, returncode=1)
|
||||
|
||||
|
||||
def _owned_run() -> bool:
|
||||
"""An outer ProcessContainer will reap this run, so aborting live workers strands nothing.
|
||||
|
||||
Without one (a bare pytest), xdist's own crash handling lets running tests finish and
|
||||
clean up their children: nobody else would.
|
||||
"""
|
||||
from ouroboros.process_containment import CONTAINMENT_ENV_PREFIX
|
||||
|
||||
return any(key.startswith(CONTAINMENT_ENV_PREFIX) for key in os.environ)
|
||||
|
||||
|
||||
def _record_worker_crash(node, reason, scheduler):
|
||||
collected = getattr(scheduler, "collection", None)
|
||||
completed = getattr(node.config, "_ouroboros_completed_tests", set())
|
||||
node.config._ouroboros_worker_crash = {
|
||||
"worker": node.gateway.id, "reason": str(reason),
|
||||
"collection_available": collected is not None,
|
||||
"unfinished": sorted(set(collected or []) - completed),
|
||||
}
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
|
||||
def pytest_runtest_logreport(report):
|
||||
outcome = yield
|
||||
node = getattr(report, "node", None)
|
||||
config = getattr(node, "config", None)
|
||||
session = getattr(config, "_ouroboros_session", None)
|
||||
if config is not None:
|
||||
completed = getattr(config, "_ouroboros_completed_tests", set())
|
||||
if report.when in {"teardown", "???"} or report.failed or report.skipped:
|
||||
completed.add(report.nodeid)
|
||||
config._ouroboros_completed_tests = completed
|
||||
if report.when == "???" and session is not None and session.shouldfail:
|
||||
controller = config.pluginmanager.get_plugin("dsession")
|
||||
_record_worker_crash(node, session.shouldfail, controller.sched)
|
||||
if not _owned_run():
|
||||
return
|
||||
# All consumers have now recorded xdist's native crash report. Do not
|
||||
# await another worker's running test: bounded gateway teardown and the
|
||||
# outer ProcessContainer own shutdown; evidence retains unfinished IDs.
|
||||
error = pytest.exit.Exception(str(session.shouldfail), returncode=1)
|
||||
if hasattr(outcome, "force_exception"):
|
||||
outcome.force_exception(error)
|
||||
else: # pluggy < 1.1, still allowed by pytest 7's dependency range.
|
||||
raise error
|
||||
|
||||
|
||||
def pytest_terminal_summary(terminalreporter):
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@ guard. One section per Q10 item:
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
# --- Q10 item 1: OUROBOROS_SCOPE_REVIEW_FLOOR ---------------------------------
|
||||
|
|
@ -81,27 +78,6 @@ def test_scope_review_floor_and_semantic_owner_detectors_are_gone():
|
|||
assert not hasattr(registry, "_detect_safety_mode_self_lowering")
|
||||
|
||||
|
||||
def test_scope_review_floor_left_no_source_remnants():
|
||||
"""Grep-level absence over the runtime tree: no code path may still spell
|
||||
the retired key or endpoint (docs/history and the retirement entry itself
|
||||
are the only legitimate spellings)."""
|
||||
allowed = {
|
||||
pathlib.Path("ouroboros/settings_defaults.py"), # RETIRED_SETTING_KEYS entry
|
||||
pathlib.Path("ouroboros/tools/registry_guard_process.py"), # historical incident comment
|
||||
}
|
||||
offenders = []
|
||||
for root in ("ouroboros", "supervisor", "web", "prompts"):
|
||||
for path in sorted((REPO / root).rglob("*")):
|
||||
if not path.is_file() or path.suffix not in {".py", ".js", ".md", ".html"}:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if "SCOPE_REVIEW_FLOOR" in text.upper() or "scope-review-floor" in text:
|
||||
rel = path.relative_to(REPO)
|
||||
if rel not in allowed:
|
||||
offenders.append(rel.as_posix())
|
||||
assert not offenders, f"retired floor surface respelled in: {offenders}"
|
||||
|
||||
|
||||
# --- Q10 item 2: fail_tasks (budget-drain terminalizer) -----------------------
|
||||
|
||||
def test_fail_tasks_is_gone_and_pause_scenario_supersedes_it():
|
||||
|
|
@ -116,15 +92,6 @@ def test_fail_tasks_is_gone_and_pause_scenario_supersedes_it():
|
|||
assert "E13" in SCENARIOS
|
||||
|
||||
|
||||
def test_fail_tasks_left_no_source_remnants():
|
||||
offenders = []
|
||||
for root in ("ouroboros", "supervisor", "web", "prompts"):
|
||||
for path in sorted((REPO / root).rglob("*.py")):
|
||||
if "fail_tasks" in path.read_text(encoding="utf-8", errors="replace"):
|
||||
offenders.append(path.relative_to(REPO).as_posix())
|
||||
assert not offenders, f"removed fail_tasks respelled in: {offenders}"
|
||||
|
||||
|
||||
# --- Q10 item 3: until_deadline / stall_rounds_threshold aliases --------------
|
||||
|
||||
def test_deadline_pacing_aliases_are_gone_from_the_contract_shape():
|
||||
|
|
@ -156,32 +123,3 @@ def test_deadline_no_longer_lifts_the_improvement_count_axis(monkeypatch):
|
|||
assert effective_max_improvement_passes({**profile, "max_improvement_passes": 1}) == 1
|
||||
params = inspect.signature(effective_max_improvement_passes).parameters
|
||||
assert "has_deadline" not in params
|
||||
|
||||
|
||||
def test_deadline_aliases_left_no_source_remnants():
|
||||
"""FUNCTIONAL remnants only: a resurrected alias needs the QUOTED string
|
||||
literal (a comparison, dict key or event type); prose comments disclosing
|
||||
the removal legitimately keep the bare word.
|
||||
|
||||
One exemption: the ABI-2 quarantine classifier
|
||||
(``ouroboros/task_result_schema.py``) is the removal's ENFORCEMENT arm —
|
||||
it must spell the retired token to REFUSE a stored contract still
|
||||
carrying it (reason ``retired_contract_until_deadline``, ledger f30
|
||||
entry 4). That comparison sends rows to quarantine; it cannot make the
|
||||
alias functional again."""
|
||||
functional = (
|
||||
'"until_deadline"', "'until_deadline'",
|
||||
'"stall_rounds_threshold"', "'stall_rounds_threshold'",
|
||||
"deprecated_task_pacing_alias",
|
||||
)
|
||||
refusal_only = {"ouroboros/task_result_schema.py"}
|
||||
offenders = []
|
||||
for root in ("ouroboros", "supervisor", "web", "prompts"):
|
||||
for path in sorted((REPO / root).rglob("*.py")):
|
||||
rel = path.relative_to(REPO).as_posix() # Windows walks with backslashes
|
||||
if rel in refusal_only:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if any(tok in text for tok in functional):
|
||||
offenders.append(rel)
|
||||
assert not offenders, f"removed pacing aliases respelled in: {offenders}"
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
"""ABI-6 removal pins: the three F3.0 P1 removals, proven negatively.
|
||||
|
||||
The F3.0 opening train removed three dead surfaces (`03a835b9`, `e94f063d`):
|
||||
the `_call_llm_with_retry` alias in `ouroboros/loop.py`, and the second
|
||||
with-children cost rollup `compute_cost_with_children` plus the unused
|
||||
`format_handoff_message` in `ouroboros/task_status.py`. Each removal was
|
||||
gated only by the suites of the code that SURVIVED, so every one of those
|
||||
hooks stays green if an alias comes back — a positive suite cannot notice an
|
||||
extra name. The ADOPTION row R ABI-6 carries that as a disclosed residual:
|
||||
"proven by surviving positive suites plus grep-level absence, not by
|
||||
dedicated negative pins". This file is the missing negative pin.
|
||||
|
||||
The assertions are deliberately two-sided. Absence alone is satisfied by
|
||||
deleting the module, so each retired name is paired with the canonical
|
||||
surface that replaced it: a resurrection is red, and so is losing the
|
||||
replacement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
# (former owner module, retired name, what carries the job now)
|
||||
RETIRED = (
|
||||
("ouroboros.loop", "_call_llm_with_retry",
|
||||
"the public call_llm_with_retry (the one patch point)"),
|
||||
("ouroboros.task_status", "compute_cost_with_children",
|
||||
"the producer rollup in agent_task_pipeline/post_task_synthesis, "
|
||||
"projected through cost_projection.py"),
|
||||
("ouroboros.task_status", "format_handoff_message",
|
||||
"the absorption / wait_tasks projections"),
|
||||
("ouroboros.task_status", "_handoff_snippet",
|
||||
"nothing — private helper of the removed formatter"),
|
||||
("ouroboros.task_status", "HANDOFF_SNIPPET_CHARS",
|
||||
"nothing — private constant of the removed formatter"),
|
||||
)
|
||||
|
||||
|
||||
def _names_the_symbol(source: str, name: str) -> bool:
|
||||
"""Whole-identifier match, so `fake_call_llm_with_retry` in a test double
|
||||
or `call_llm_with_retry` itself never counts as the retired spelling."""
|
||||
return re.search(rf"(?<![A-Za-z0-9_]){re.escape(name)}\b", source) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("module_name", "retired", "successor"), RETIRED,
|
||||
ids=[f"{m.rsplit('.', 1)[-1]}.{n}" for m, n, _ in RETIRED])
|
||||
def test_a_retired_p1_name_is_absent_from_its_former_owner(module_name, retired, successor):
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
assert not hasattr(module, retired), (
|
||||
f"{module_name}.{retired} is back; the job belongs to {successor}")
|
||||
# A re-export binds the attribute too, but a re-introduced *def* under a
|
||||
# conditional or inside a class body would not — the source check catches
|
||||
# the spelling wherever it reappears in the former owner.
|
||||
assert not _names_the_symbol(inspect.getsource(module), retired), (
|
||||
f"{module_name} names {retired} again")
|
||||
|
||||
|
||||
def test_the_surviving_llm_retry_surface_is_the_public_one():
|
||||
"""The alias existed "for source-inspecting/monkeypatched tests"; every
|
||||
such test targets the public name, which must therefore still be there."""
|
||||
from ouroboros import loop
|
||||
|
||||
assert callable(loop.call_llm_with_retry)
|
||||
|
||||
|
||||
def test_the_one_with_children_cost_rollup_is_the_projection_ssot():
|
||||
"""`compute_cost_with_children` was the SECOND, diverging rollup. The
|
||||
remaining one is the producer path's, read through the projection SSOT."""
|
||||
from ouroboros import cost_projection, task_status
|
||||
|
||||
assert callable(cost_projection.live_root_cost_projection)
|
||||
assert callable(cost_projection.cost_projection)
|
||||
# task_status keeps its own non-rollup readers (the child lookup the
|
||||
# removal's own commit preserved), so the module is not simply gone.
|
||||
assert callable(task_status.find_child_tasks)
|
||||
|
|
@ -5,7 +5,6 @@ from pathlib import Path
|
|||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from tests._governance_docs_shared import architecture_text, development_text
|
||||
|
||||
|
||||
def test_partial_trajectory_is_non_resolving_but_complete_trajectory_resolves():
|
||||
|
|
@ -687,15 +686,6 @@ def test_budget_ladder_stops_shedding_after_predecessor_fits():
|
|||
)
|
||||
|
||||
|
||||
def test_acceptance_docs_have_complete_sentence_boundaries():
|
||||
development = development_text()
|
||||
architecture = architecture_text()
|
||||
|
||||
assert "silent false green. The\n Every forced rail" not in development
|
||||
assert "never certify success; An OPEN plan wave" not in architecture
|
||||
assert "never had claims. an unresolved reference" not in architecture
|
||||
|
||||
|
||||
def test_refused_slot_reuses_the_already_persisted_prompt(monkeypatch, tmp_path):
|
||||
import ouroboros.review_substrate as substrate
|
||||
|
||||
|
|
|
|||
|
|
@ -75,9 +75,26 @@ def test_the_sweep_never_follows_a_symlink_out_of_the_drive(tmp_path):
|
|||
assert report == {"removed": 1, "kept": 0, "skipped": 2, "errors": 0}
|
||||
|
||||
|
||||
def test_startup_prune_sweeps_run_the_media_prune():
|
||||
import inspect
|
||||
|
||||
def test_startup_prune_sweeps_run_the_media_prune(tmp_path, monkeypatch):
|
||||
import ouroboros.server_maintenance as sm
|
||||
|
||||
assert "prune_agent_media_uploads" in inspect.getsource(sm._run_deferred_startup_prunes)
|
||||
shots = tmp_path / "uploads" / "screenshots"
|
||||
shots.mkdir(parents=True)
|
||||
old_shot = shots / "old.png"
|
||||
old_shot.write_bytes(b"agent image")
|
||||
owner_upload = shots.parent / "owner.png"
|
||||
owner_upload.write_bytes(b"owner image")
|
||||
for path in (old_shot, owner_upload):
|
||||
os.utime(path, (_OLD, _OLD))
|
||||
|
||||
monkeypatch.setattr(sm, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(sm, "_STARTUP_PRUNES_OWED", [True])
|
||||
monkeypatch.setattr(sm, "_STARTUP_TREES_OWED", [False])
|
||||
monkeypatch.setattr(sm, "_STARTUP_SOURCE_PRUNES_OWED", [False])
|
||||
monkeypatch.setattr(sm, "_startup_worktree_prune", lambda: None)
|
||||
monkeypatch.setattr(sm, "_prune_delegated_snapshots", lambda: None)
|
||||
|
||||
sm._run_deferred_startup_prunes()
|
||||
|
||||
assert not old_shot.exists()
|
||||
assert owner_upload.read_bytes() == b"owner image"
|
||||
|
|
|
|||
|
|
@ -66,39 +66,6 @@ def _read(name: str) -> str:
|
|||
return (MODULES / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _swept_sources(*, with_tests: bool) -> list[tuple[str, str, str]]:
|
||||
"""(label, text, line-comment marker) for every source an address can hide in.
|
||||
|
||||
The sweep this file first shipped looked at FOUR modules. That is precisely
|
||||
why a stale pointer survived in ``ouroboros/tools/plan_review.py``, which no
|
||||
sweep was looking at: the owner was told to "Fix Reviewer Slots in Settings"
|
||||
long after the section stopped existing under that name. So the scan is now
|
||||
the whole surface this repo authors, not the files the move happened to touch.
|
||||
|
||||
``with_tests`` is off for the copy sweep and on for the address sweep. A test
|
||||
asserting that a retired WORD is absent has to spell that word; a test
|
||||
carrying a retired ADDRESS in a comment misdirects its next reader exactly
|
||||
like production code does.
|
||||
"""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
groups: list[tuple[list[pathlib.Path], str | None]] = [
|
||||
(sorted((REPO_ROOT / "web" / "modules").glob("*.js")), "//"),
|
||||
(sorted((REPO_ROOT / "web").glob("*.css")), None),
|
||||
(sorted((REPO_ROOT / "ouroboros").rglob("*.py")), "#"),
|
||||
(sorted((REPO_ROOT / "supervisor").rglob("*.py")), "#"),
|
||||
]
|
||||
if with_tests:
|
||||
groups.append((sorted((REPO_ROOT / "web" / "tests").glob("*.js")), "//"))
|
||||
groups.append((sorted((REPO_ROOT / "tests").glob("*.py")), "#"))
|
||||
for paths, comment in groups:
|
||||
for path in paths:
|
||||
if path.resolve() == pathlib.Path(__file__).resolve():
|
||||
continue
|
||||
out.append((str(path.relative_to(REPO_ROOT)),
|
||||
path.read_text(encoding="utf-8"), comment or "\0"))
|
||||
return out
|
||||
|
||||
|
||||
def _page_markup() -> str:
|
||||
return "\n".join(_read(name) for name in PAGE_SOURCES)
|
||||
|
||||
|
|
@ -224,80 +191,6 @@ def test_the_two_path_roots_are_behind_a_collapsed_disclosure() -> None:
|
|||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The naming sweep
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_owner_facing_coding_agent_copy_remains_on_the_agents_surfaces() -> None:
|
||||
"""D-10, owner verbatim: «мне не нравится "coding" потому что эти агенты не
|
||||
только для кода используются, например для создания презентаций и любые
|
||||
tasks». Product names (Claude Code, Codex, Cursor) are unaffected — they are
|
||||
trademarks, not the generic label.
|
||||
|
||||
Scoped to OWNER-FACING copy: comments are skipped, because a comment that
|
||||
explains why the word was retired has to be able to spell it. NOT scoped to
|
||||
the modules this phase happened to edit — a sweep narrowed to the changed
|
||||
files only proves the change, never the rule."""
|
||||
offenders: list[str] = []
|
||||
for label, text, comment in _swept_sources(with_tests=False):
|
||||
for lineno, line in enumerate(text.splitlines(), 1):
|
||||
if line.lstrip().startswith(comment):
|
||||
continue
|
||||
if re.search(r"coding[- ]agent", line, re.IGNORECASE):
|
||||
offenders.append(f"{label}:{lineno}: {line.strip()}")
|
||||
assert not offenders, (
|
||||
"owner-facing copy still says \"coding agent\" (D-10)\n" + "\n".join(offenders))
|
||||
|
||||
|
||||
# Every address this phase INVALIDATED, with the place that replaced it. A
|
||||
# pointer is not decoration: it is an instruction, and one that names a heading
|
||||
# no tab carries wastes the owner's time and quietly claims the feature is
|
||||
# missing. Comments count — a developer sent to the Models page for the reviewer
|
||||
# rows loses the same minutes the owner does.
|
||||
STALE_ADDRESSES = (
|
||||
(r"Reviewer Slots",
|
||||
"reviewers are the rows marked Reviewer in Agents → Available subagents"),
|
||||
(r"Models[- ]page|Models tab \(Reviewer",
|
||||
"the reviewer rows and the subagent route left the Models page for Agents (D-10)"),
|
||||
(r"Providers\s*(?:→|->)\s*Harness Accounts",
|
||||
"accounts are Agents → Accounts now, grouped per family (D-10)"),
|
||||
(r"Models\s*(?:→|->)\s*(?:Subagents|Reviewer)",
|
||||
"delegation and reviewers are Agents → Available subagents (D-10)"),
|
||||
)
|
||||
|
||||
|
||||
def _stale_address_hits(text: str) -> list[tuple[int, str, str]]:
|
||||
hits: list[tuple[int, str, str]] = []
|
||||
for lineno, line in enumerate(text.splitlines(), 1):
|
||||
for pattern, correction in STALE_ADDRESSES:
|
||||
if re.search(pattern, line):
|
||||
hits.append((lineno, line.strip(), correction))
|
||||
return hits
|
||||
|
||||
|
||||
def test_no_source_still_sends_anyone_to_a_section_this_phase_removed() -> None:
|
||||
"""The class the narrow sweep let through.
|
||||
|
||||
D-10 moved Harness Accounts out of Providers and renamed Reviewer Slots, and
|
||||
the addresses that named those places stayed behind — in owner-facing copy
|
||||
(``plan_review`` told the owner to "Fix Reviewer Slots in Settings") and in
|
||||
the comments a maintainer navigates by. Both are wrong in the same way, so
|
||||
both are swept, across every source this repo authors rather than the
|
||||
handful the move touched.
|
||||
|
||||
Historical narration is welcome — but it must describe the old place
|
||||
("the accounts section under the Providers tab") rather than repeat the dead
|
||||
address verbatim, so a reader searching for a heading finds only live ones.
|
||||
"""
|
||||
offenders: list[str] = []
|
||||
for label, text, _comment in _swept_sources(with_tests=True):
|
||||
for lineno, line, correction in _stale_address_hits(text):
|
||||
offenders.append(f"{label}:{lineno}: {line}\n → {correction}")
|
||||
assert not offenders, (
|
||||
"a stale section address survived the move:\n" + "\n".join(offenders))
|
||||
|
||||
|
||||
def test_a_spent_window_is_emphasised_rather_than_dimmed() -> None:
|
||||
"""Caught by looking at the rendered tab: `.harness-exhausted` dimmed the whole
|
||||
row to 0.62 opacity, which greyed out the very sentence reporting the limit
|
||||
|
|
@ -323,23 +216,3 @@ def test_a_spent_window_is_emphasised_rather_than_dimmed() -> None:
|
|||
"sentence and disables-looking buttons that still work"
|
||||
)
|
||||
assert "var(--status-warn-" in rule.group(1)
|
||||
|
||||
|
||||
def test_the_reviewer_disclosure_stopped_advising_against_the_default() -> None:
|
||||
"""The sentence used to end "keep at least one API reviewer row to avoid the
|
||||
fallback", which asks the owner to undo the ratified default (D-3: with a
|
||||
subscription connected, everything that can run on one does, and a triad is
|
||||
never half API and half subscription). Its carrier — the conditional
|
||||
all-delegated warning about a task-acceptance API fallback — is gone with the
|
||||
fallback itself (owner R2/R12, 2026-09-01): acceptance follows the rows. The
|
||||
ONE-TIME packet→retrieving migration disclosure that was the last server-side
|
||||
sentence left with the review lanes (PR-3): delivery is a field of the catalog
|
||||
row the owner saves, so no sentence advises for or against a delivery."""
|
||||
config = (REPO_ROOT / "ouroboros" / "reviewer_slot_config.py").read_text(encoding="utf-8")
|
||||
assert "Keep at least one API reviewer row" not in config
|
||||
assert "never fall back" not in config and "stays API-only" not in config
|
||||
assert "acceptance_delivery_disclosure" not in config and "Keep an api_chat row" not in config
|
||||
# Reviewers are catalog rows now; their editor carries the standing rule.
|
||||
editor = " ".join(_read("subagents_settings.js").split())
|
||||
assert "keep at least one API row" not in editor.lower()
|
||||
assert "waits for capacity rather than silently falling back to API spend" in editor
|
||||
|
|
|
|||
|
|
@ -49,14 +49,6 @@ def test_every_first_party_window_uses_a_persistent_profile():
|
|||
assert calls == 5, f"expected 5 first-party webview.start() calls, found {calls}"
|
||||
|
||||
|
||||
def test_the_owner_approved_cost_is_written_down_where_it_is_paid():
|
||||
# A flag that makes local site data persist on disk is an owner decision,
|
||||
# not an implementation detail; the rationale must survive the next reader.
|
||||
main = _read("launcher.py")
|
||||
assert "cookies and website data" in main
|
||||
assert "ouroboros.theme" in main
|
||||
|
||||
|
||||
def test_the_select_arrow_is_a_theme_token_not_a_baked_colour():
|
||||
ui = _read("web/ui.css")
|
||||
dark, light = ui.split(':root[data-theme="light"]', 1)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ def reexports(manifest):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_owner_of_resolves_a_module_path_a_dotted_module_and_a_symbol():
|
||||
from ouroboros.code_intelligence import build_code_inventory
|
||||
|
||||
(llm,) = owner_of(REPO, "ouroboros/llm.py")
|
||||
assert (llm.domain, llm.via) == ("D02", "module_path")
|
||||
assert llm.domain_title # the human vocabulary rides along
|
||||
|
|
@ -60,7 +62,14 @@ def test_owner_of_resolves_a_module_path_a_dotted_module_and_a_symbol():
|
|||
"supervisor/state.py", "D08", "dotted_module",
|
||||
)
|
||||
|
||||
owners = owner_of(REPO, "protected_path_category")
|
||||
# This oracle asks about one real definition, not every repository language.
|
||||
# Keep the live carrier/parse witness without indexing tests, JS and fixtures;
|
||||
# the tiny repository below exercises owner_of's default inventory path.
|
||||
inventory = build_code_inventory(
|
||||
REPO, scope="ouroboros/runtime_mode_policy.py", persist=False,
|
||||
)
|
||||
assert not inventory.enumeration_limits
|
||||
owners = owner_of(REPO, "protected_path_category", inventory=inventory)
|
||||
assert any(
|
||||
(o.module, o.domain, o.via) == ("ouroboros/runtime_mode_policy.py", "D13", "symbol_definition")
|
||||
for o in owners
|
||||
|
|
@ -342,6 +351,10 @@ def test_query_code_owner_of_qualifies_partial_symbol_coverage(tmp_path, monkeyp
|
|||
(repo / "ouroboros" / "a.py").write_text("def shared():\n pass\n", encoding="utf-8")
|
||||
(repo / "ouroboros" / "z.py").write_text(
|
||||
"def shared():\n pass\n\n\ndef late():\n pass\n", encoding="utf-8")
|
||||
assert [(row.module, row.via) for row in owner_of(repo, "shared")] == [
|
||||
("ouroboros/a.py", "symbol_definition"),
|
||||
("ouroboros/z.py", "symbol_definition"),
|
||||
]
|
||||
registry = ToolRegistry(repo_dir=repo, drive_root=tmp_path / "data")
|
||||
|
||||
def owner(arg):
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ def _read(path: pathlib.Path) -> str:
|
|||
def test_available_subagents_is_one_canonical_settings_editor() -> None:
|
||||
editor = _read(MODULES / "subagents_settings.js")
|
||||
host = _read(MODULES / "settings.js")
|
||||
assert "<h3>Available subagents</h3>" in editor
|
||||
assert "OUROBOROS_SUBAGENTS" in editor
|
||||
assert "collectSubagentsSettings" in host
|
||||
assert "OUROBOROS_SUBAGENT_HARNESS" not in editor
|
||||
|
|
@ -51,17 +50,12 @@ def test_a_fresh_subagent_row_invites_and_only_a_save_attempt_makes_it_red() ->
|
|||
assert "row._uiAttempted = true" in editor
|
||||
assert "Boolean(row._uiAttempted) && " in editor
|
||||
assert "row._uiAttempted && errors.length" in primitives
|
||||
assert "Choose how this subagent runs: an API model or an agent session." in primitives
|
||||
host = _read(MODULES / "settings.js")
|
||||
# Every Save click is an attempt — including one another field's validation
|
||||
# then aborts — so the stamp precedes the cadence check's early return.
|
||||
save = host[host.index("byId('btn-save-settings').addEventListener"):]
|
||||
assert save.index("noteSubagentsSaveAttempt();") < save.index("const { messages: errors, subject } = renderValidation();")
|
||||
assert "Every-N cadence needs" in host
|
||||
assert "agentsStep?.noteSaveAttempt?.();" in _read(MODULES / "onboarding_wizard.js")
|
||||
# Errors name the card the way its heading does, never a bare "Row N".
|
||||
assert "`Subagent ${index + 1} ${text}`" in editor
|
||||
assert "`Row ${index + 1}`" not in editor
|
||||
|
||||
|
||||
def test_the_review_pool_is_a_catalog_mark_and_the_route_primitives_stay_reviewer_free() -> None:
|
||||
|
|
@ -82,19 +76,14 @@ def test_the_review_pool_is_a_catalog_mark_and_the_route_primitives_stay_reviewe
|
|||
def test_heavy_card_is_gone_but_provider_test_contract_and_controls_remain() -> None:
|
||||
ui = _read(MODULES / "settings_ui.js")
|
||||
host = _read(MODULES / "settings.js")
|
||||
setup = _read(ROOT / "ouroboros" / "settings_setup_contract.py")
|
||||
assert "['Heavy'," not in ui
|
||||
assert "s-model-heavy" not in ui
|
||||
assert "OUROBOROS_MODEL_HEAVY" not in host
|
||||
light_copy = "Fast summaries, lightweight internal work and the default Fast scout. Empty uses Main."
|
||||
# Role labels and copy come from the shared setup contract, not a second
|
||||
# hand-maintained card table in Settings.
|
||||
assert "modelRolesHost('settings-model-roles')" in ui
|
||||
assert "modelRoles.load(s," in host
|
||||
assert "slot.note" in _read(MODULES / "model_roles.js")
|
||||
assert light_copy in setup
|
||||
assert "all deep subagents" not in ui
|
||||
assert "all deep subagents" not in setup
|
||||
assert "data-provider-test" in ui
|
||||
assert "PROVIDER_TEST_INPUTS" in ui
|
||||
assert "apiClient.providerTest({ provider_id: provider, overrides })" in host
|
||||
|
|
@ -124,7 +113,6 @@ def test_preview_contract_and_task_only_agy_copy_are_explicit() -> None:
|
|||
assert "{ harness: 'agy' }" in step
|
||||
assert "familyLabel(family.harness" in step
|
||||
assert "{ harness: 'agy', label:" not in step
|
||||
assert "task-only and does" in step
|
||||
assert "@typedef {Object} SubagentLastDelegation" in types
|
||||
assert "@property {SubagentLastDelegation=} subagent_last_delegation" in types
|
||||
|
||||
|
|
@ -142,11 +130,7 @@ def test_generated_preview_is_background_and_whole_draft_clean_gated() -> None:
|
|||
|
||||
def test_status_refresh_and_active_task_copy_keep_the_frozen_semantics() -> None:
|
||||
editor = _read(MODULES / "subagents_settings.js")
|
||||
host = _read(MODULES / "settings.js")
|
||||
assert "await boundedStatusRefresh(store);" in editor
|
||||
assert "Saved rows remain unchanged" in editor
|
||||
assert "take effect for new child tasks" in host
|
||||
assert "the current task keeps its existing routes" in host
|
||||
|
||||
|
||||
def test_new_frontend_modules_stay_within_the_context_target() -> None:
|
||||
|
|
|
|||
|
|
@ -474,14 +474,12 @@ def test_legacy_heavy_read_vocabulary_does_not_leak_into_new_projection(
|
|||
|
||||
def test_clb_operator_adapters_transport_canonical_actor_without_active_heavy():
|
||||
patch_root = REPO / "devtools/benchmarks/continual_learning/operator_patches"
|
||||
host_patch = (patch_root / "_launcher.v6560.patch").read_text(encoding="utf-8")
|
||||
docker_patch = (patch_root / "clb_env_campaign_overrides.v6745.patch").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
official_patch = (patch_root / "adapter_official_submission.v681.patch").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "Canonical benchmark actor bytes are authored by run_clb.py" in host_patch
|
||||
assert '"OUROBOROS_SUBAGENTS"):' in docker_patch
|
||||
assert "OUROBOROS_SUBAGENTS=os.environ.get" in official_patch
|
||||
assert "OUROBOROS_MODEL_HEAVY" not in official_patch
|
||||
|
|
|
|||
|
|
@ -633,16 +633,6 @@ def test_activity_rows_show_a_held_budget_row_as_paused_not_queued():
|
|||
assert "|| heldRow(t)" in source # consulted by the pending-row pause predicate
|
||||
|
||||
|
||||
def test_runbook_does_not_promise_a_managed_outage_window_the_runtime_has_no_rail_for():
|
||||
"""F8: with no deadline and an unlimited absolute ceiling, a managed task's
|
||||
transport-outage episode has NO window of its own — the 6h operation-window
|
||||
fallback belongs to other operations. The runbook names the optional rails an
|
||||
operator can set instead of promising a timeout that does not exist."""
|
||||
source = _repo_file("devtools", "benchmarks", "continual_learning", "RUNBOOK.md")
|
||||
assert "6h operation window from episode entry" not in source
|
||||
assert "OUROBOROS_TASK_ABS_CEILING_SEC" in source and "idle reaper" in source
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- consumption publication holds
|
||||
|
||||
def _granted_loop(tmp_path, monkeypatch, task_id):
|
||||
|
|
|
|||
|
|
@ -35,12 +35,6 @@ def _read(rel: str) -> str:
|
|||
# ────────── Bug 1 (polish): the retired loop's rows replay honestly ──────────
|
||||
|
||||
|
||||
def test_log_events_has_no_background_loop_projector():
|
||||
src = _read("web/modules/log_events.js")
|
||||
assert "consciousness_state" not in src
|
||||
assert "bg-consciousness" not in src
|
||||
|
||||
|
||||
def test_history_never_fabricates_a_terminal_status_for_legacy_bg_rows(tmp_path):
|
||||
from ouroboros.gateway.history import make_chat_history_endpoint
|
||||
|
||||
|
|
|
|||
|
|
@ -59,16 +59,9 @@ class TestBuildSh:
|
|||
assert "playwright install --only-shell chromium" in src, (
|
||||
"build.sh must call 'playwright install --only-shell chromium' on macOS"
|
||||
)
|
||||
assert "Skipping bundled WebKit on macOS" in src, (
|
||||
"build.sh must document the macOS WebKit packaging policy"
|
||||
)
|
||||
assert "Removing stale bundled WebKit payloads from macOS package tree" in src
|
||||
assert 'rglob(".local-browsers")' in src
|
||||
assert 'glob("webkit-*")' in src
|
||||
assert "shutil.rmtree" in src
|
||||
assert "managed" in src and "Playwright cache" in src, (
|
||||
"build.sh must leave WebKit available through the managed runtime cache"
|
||||
)
|
||||
assert "playwright install webkit" not in src
|
||||
assert "pw.webkit.launch" not in src
|
||||
|
||||
|
|
@ -653,7 +646,6 @@ class TestRepoBundleReleaseTagGuard:
|
|||
def test_dirty_tree_refusal_reports_the_offending_paths(self):
|
||||
src = _read("scripts/build_repo_bundle.py")
|
||||
assert '"status", "--porcelain"' in src
|
||||
assert '"Commit or stash changes first so the embedded managed repo matches the packaged code.\\n"' in src
|
||||
assert "+ status" in src
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
"""WS2-e (capinv-447) contract tests: one-author projections + honest results.
|
||||
|
||||
Covers H2 (write-like typing across the two profile projections), H3 (module
|
||||
load failures reach the capability ledger and survive schemas() rebuilds), H4
|
||||
(typed preflight_blocked reason_kind), H5 (repo_commit_ready SSOT is
|
||||
enforcement-aware), D3 (search filter receipt), D4 (export member policy reuses
|
||||
the workspace-patch SSOT), G10 (attachment routes unified + rule-named
|
||||
load failures reach the capability ledger and survive schemas() rebuilds),
|
||||
D3 (search filter receipt), D4 (export member policy reuses the workspace-patch
|
||||
SSOT), G10 (attachment routes unified + rule-named
|
||||
reasons), G3 (npm version pins + manual-dependency disclosure), and E5 (MCP
|
||||
pagination, injective tool slugs, resource/structuredContent fidelity).
|
||||
"""
|
||||
|
|
@ -115,21 +114,6 @@ def test_module_load_failure_recorded_and_survives_schema_rebuilds(tmp_path, mon
|
|||
# H5 — the retired advisory gate (3A) projects no readiness axis anywhere
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_retired_advisory_gate_has_no_readiness_projection_left():
|
||||
"""Decision 3A retired the advisory gate; its `repo_commit_ready` mirror said
|
||||
"yes" on every surface and made the author chase a readiness that nothing
|
||||
decides. Neither the function nor the context heading exists any more."""
|
||||
import inspect
|
||||
|
||||
from ouroboros import agent_task_pipeline, review_state
|
||||
|
||||
assert not hasattr(review_state, "advisory_commit_ready")
|
||||
source = inspect.getsource(agent_task_pipeline)
|
||||
assert "Live repo gate" not in source
|
||||
assert "Advisory readiness" not in source
|
||||
assert "repo_commit_ready" not in source
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D3 — search filter receipt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""The layered change-review checklist (docs/CHECKLISTS.md): the universal
|
||||
`Change Review Checklist` core every reviewed change carries, and the
|
||||
`Ouroboros Body Layer` appended only when the subject IS the body
|
||||
(`review_body_fact.layer_for`). Pins the loader, the numbering contract, the
|
||||
fingerprint and the retirement of the former single-section name."""
|
||||
(`review_body_fact.layer_for`). Pins the loader, the numbering contract, and the
|
||||
fingerprint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -24,7 +24,6 @@ from ouroboros.tools.review_helpers import (
|
|||
|
||||
REPO = pathlib.Path(__file__).resolve().parent.parent
|
||||
CHECKLIST = REPO / "docs" / "CHECKLISTS.md"
|
||||
RETIRED_SECTION_NAME = "Repo Commit" + " Checklist" # spelled apart so this file never carries the name
|
||||
ITEM_ROW = re.compile(r"^\| (\d+) \| ([a-z_]+) \|", re.MULTILINE)
|
||||
|
||||
|
||||
|
|
@ -85,16 +84,6 @@ def test_the_core_layer_names_no_body_document():
|
|||
assert marker not in core, marker
|
||||
|
||||
|
||||
def test_the_retired_section_name_is_gone_from_the_executing_code_and_docs():
|
||||
hits: list[str] = []
|
||||
for tree in ("ouroboros", "prompts", "docs"):
|
||||
for path in sorted((REPO / tree).rglob("*")):
|
||||
if path.is_file() and path.suffix in {".py", ".md", ".txt", ".toml", ".json"}:
|
||||
if RETIRED_SECTION_NAME in path.read_text(encoding="utf-8", errors="replace"):
|
||||
hits.append(path.relative_to(REPO).as_posix())
|
||||
assert hits == [], hits
|
||||
|
||||
|
||||
def test_fingerprint_names_the_rules_that_ran():
|
||||
core = checklist_fingerprint("core")
|
||||
body = checklist_fingerprint("body")
|
||||
|
|
|
|||
|
|
@ -1058,12 +1058,28 @@ def test_every_canonical_store_publisher_takes_the_one_custody_lock(tmp_path):
|
|||
settlement, so it takes the same custody lock and answers ``CustodyBusy`` while it is held."""
|
||||
data, drive, _store = _nested_child(tmp_path)
|
||||
task = {"id": TASK, "drive_root": str(drive)}
|
||||
lock_budget = 0.01 # Exercise real contention without waiting out the production budget.
|
||||
with task_custody.task_custody_lock(data, TASK) as locked:
|
||||
assert locked
|
||||
with pytest.raises(task_custody.CustodyBusy):
|
||||
headless.finalize_task_artifacts(data, task)
|
||||
headless.finalize_task_artifacts(data, task, lock_timeout_sec=lock_budget)
|
||||
assert not (artifacts.task_artifact_dir_path(data, TASK) / "memory_export.json").exists()
|
||||
assert headless.finalize_task_artifacts(data, task) # released: the same work completes
|
||||
assert headless.finalize_task_artifacts(data, task, lock_timeout_sec=lock_budget) # released: the same work completes
|
||||
|
||||
|
||||
def test_artifact_publication_keeps_the_production_lock_budget(tmp_path, monkeypatch):
|
||||
from contextlib import nullcontext
|
||||
|
||||
budgets = []
|
||||
|
||||
def unavailable(_root, _task_id, *, timeout_sec):
|
||||
budgets.append(timeout_sec)
|
||||
return nullcontext(False)
|
||||
|
||||
monkeypatch.setattr(task_custody, "task_custody_lock", unavailable)
|
||||
with pytest.raises(task_custody.CustodyBusy):
|
||||
headless.finalize_task_artifacts(tmp_path, {"id": TASK})
|
||||
assert budgets == [30.0] # Production publication may wait for a competing writer.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("when", ["before_the_custody_lock", "before_the_row_lock"])
|
||||
|
|
|
|||
|
|
@ -380,7 +380,8 @@ def _run_shell(step, tmp_path, *, diagnostic="viewport", result=0, scope="all"):
|
|||
def test_actual_producer_shell_preserves_success_and_failure_exit(tmp_path, workflow, job, step, result):
|
||||
completed, args = _run_shell(_steps(workflow, job)[step], tmp_path, result=result)
|
||||
assert completed.returncode == result, completed.stderr
|
||||
assert args.count("pytest") == 1 and "summarize" not in args
|
||||
# One producer: pytest itself, or the owned runner that forwards its pytest arguments.
|
||||
assert args.count("pytest") + args.count("scripts/run_tests.py") == 1 and "summarize" not in args
|
||||
|
||||
|
||||
OFFICIAL, FORK, TAG = "razzant/ouroboros", "someone/private-copy", "refs/tags/v7.5.2"
|
||||
|
|
@ -714,11 +715,38 @@ def test_ordinary_jobs_name_their_failures_without_owning_the_result(jobname, ar
|
|||
evidence = next(step for step in job["steps"] if step.get("uses") == EVIDENCE_ACTION)
|
||||
assert evidence["continue-on-error"] is True and "!cancelled()" in evidence["if"]
|
||||
assert evidence["with"] == {"name": artifact, "passes": " ".join(
|
||||
f"{label}=${{{{ steps.tests_{label}.outcome }}}}" for label in PASSES)}
|
||||
f"{label}=${{{{ " + (f"github.event_name == 'pull_request' && (steps.tests_selected.outputs.{label}_outcome"
|
||||
" || steps.tests_selected.outcome) || "
|
||||
if jobname == "quick-test" and label != "size" else "")
|
||||
+ f"steps.tests_{label}.outcome }}}}" for label in PASSES)}
|
||||
assert job["steps"].index(evidence) > max(job["steps"].index(steps[f"tests_{label}"])
|
||||
for label in PASSES)
|
||||
|
||||
|
||||
def test_pull_requests_run_the_selected_scope_on_windows_and_macos():
|
||||
job = _workflow("ci.yml")["jobs"]["pr-selected-os"]
|
||||
assert "pull_request" in job["if"] and job["strategy"]["matrix"]["os"] == ["windows-latest", "macos-latest"]
|
||||
producer = _steps("ci.yml", "pr-selected-os")["tests_selected"]
|
||||
assert "scripts/run_tests.py --selected --base ${{ github.event.pull_request.base.sha }} --sequential" in producer["run"]
|
||||
# Unsharded Windows cannot finish a full escalation in its ceiling; its four landed shards run it.
|
||||
assert producer["run"].endswith("${{ runner.os == 'Windows' && '--defer-full' || '' }}")
|
||||
evidence = next(step for step in job["steps"] if step.get("uses") == EVIDENCE_ACTION)
|
||||
# A step killed before run_tests.py writes its outputs still reports through its own outcome.
|
||||
assert evidence["with"]["passes"] == " ".join(
|
||||
f"{label}=${{{{ steps.tests_selected.outputs.{label}_outcome || steps.tests_selected.outcome }}}}"
|
||||
for label in ("parallel", "serial"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("job", ["quick-test", "full-test-runs"])
|
||||
def test_every_landed_test_producer_runs_under_the_owned_runner(job):
|
||||
"""One owner per test run: the full passes and the Node lane run through scripts/run_tests.py."""
|
||||
steps = _workflow("ci.yml")["jobs"][job]["steps"]
|
||||
producers = [step["run"] for step in steps if step.get("id") in {"tests_parallel", "tests_serial"}]
|
||||
node = [step["run"] for step in steps if step.get("name", "").startswith("Run browser-module tests")]
|
||||
assert len(producers) == 2 and all(run.startswith("python scripts/run_tests.py tests/ -m ") for run in producers)
|
||||
assert node == ["python scripts/run_tests.py --node"]
|
||||
|
||||
|
||||
def test_evidence_action_steps_are_independent_diagnostics():
|
||||
upload, report = _evidence_action()["runs"]["steps"]
|
||||
for step in (upload, report): # Neither waits for the other's success nor fails the caller.
|
||||
|
|
|
|||
|
|
@ -359,9 +359,22 @@ def test_successful_upload_without_a_url_is_incomplete(tmp_path):
|
|||
("import time; time.sleep(60)", ("-n", "2", "--dist", "loadscope", "--max-worker-restart=0",
|
||||
"--timeout=2", "--timeout-method=thread")),
|
||||
], ids=["exit", "ci-timeout"])
|
||||
def test_dead_xdist_worker_is_a_named_crash_and_not_an_unknown_session(tmp_path, body, args):
|
||||
def test_dead_xdist_worker_is_a_named_crash_and_not_an_unknown_session(tmp_path, monkeypatch, body, args):
|
||||
monkeypatch.setenv("OURO_TEST_WORKER_BUDGET", "2")
|
||||
result, public, _ = _producer(tmp_path, f"""
|
||||
def test_dies():
|
||||
import json, time
|
||||
from pathlib import Path
|
||||
deadline = time.monotonic() + 10
|
||||
while True:
|
||||
try:
|
||||
rows = [json.loads(line) for line in Path({str(tmp_path / 'public/events.jsonl')!r}).read_text(encoding='utf-8').splitlines()]
|
||||
except (OSError, ValueError):
|
||||
rows = []
|
||||
if any(row.get('phase') == 'teardown' and row.get('nodeid', '').endswith('::test_survives') for row in rows):
|
||||
break
|
||||
assert time.monotonic() < deadline
|
||||
time.sleep(.01)
|
||||
{body}
|
||||
class TestOtherScope:
|
||||
def test_survives(self, request):
|
||||
|
|
@ -437,7 +450,8 @@ def test_killed_session_names_the_test_in_flight_and_earlier_failures(tmp_path):
|
|||
f"::error title=Test in flight when the session was killed::{blocked}"]
|
||||
|
||||
|
||||
def test_killed_xdist_session_names_every_test_in_flight(tmp_path):
|
||||
def test_killed_xdist_session_names_every_test_in_flight(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OURO_TEST_WORKER_BUDGET", "2")
|
||||
public = _kill_when(tmp_path, _blocking(tmp_path, """
|
||||
def test_blocks_a():
|
||||
block()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import yaml
|
|||
|
||||
from ouroboros.preflight_runner import LANE_EXCLUSION_EXPR, PARALLEL_PASS_FLAGS
|
||||
from tests.ci_evidence import file_shards, reconcile_shards
|
||||
from ouroboros.test_parallelism import NESTED_WORKERS_ENV
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SHA, RUN = "a" * 40, "42"
|
||||
|
|
@ -69,6 +70,7 @@ def test_real_pytest_manifests_and_xdist_prove_both_passes_by_whole_file(tmp_pat
|
|||
result = subprocess.run(
|
||||
[sys.executable, "-I", "-S", str(ROOT / "scripts/safe_test.py"),
|
||||
"--temp-parent", str(tmp_path), "--", sys.executable, str(entry), *args],
|
||||
env={**os.environ, NESTED_WORKERS_ENV: "2"},
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=90)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
data = json.loads((public / "results.json").read_text(encoding="utf-8"))
|
||||
|
|
@ -205,7 +207,7 @@ def test_windows_matrix_keeps_the_same_passes_and_once_per_os_checks(tmp_path):
|
|||
for name in ("book_mode", "tests_size", "attachment_browser_install", "attachment_browser", "composer_browser"):
|
||||
assert "matrix.shard <= 1" in steps[name]["if"]
|
||||
for step in job["steps"]:
|
||||
if ("node --test" in step.get("run", "") or "lint:undef" in step.get("run", "")
|
||||
if ("run_tests.py --node" in step.get("run", "") or "lint:undef" in step.get("run", "")
|
||||
or "test_no_core_a2a_telegram_imports.py" in step.get("run", "")):
|
||||
assert "matrix.shard <= 1" in step["if"]
|
||||
mandatory = [step for step in job["steps"] if step.get("name", "").startswith(("Require both Windows", "Upload the Windows"))]
|
||||
|
|
|
|||
|
|
@ -516,7 +516,6 @@ def test_three_os_gate_installs_the_reviewed_runtime_instead_of_floating_npm():
|
|||
assert '--effort "${{ matrix.effort }}"' in workflow
|
||||
assert "- os: ubuntu-latest\n harness: codex" in workflow
|
||||
assert "- os: windows-latest\n harness: codex" in workflow
|
||||
assert "vendor-broken on native Windows" in workflow
|
||||
for dependency_surface in (
|
||||
".github/actions/setup-python-env/**",
|
||||
"pyproject.toml",
|
||||
|
|
|
|||
|
|
@ -70,8 +70,3 @@ def test_every_named_websocket_type_is_indexed_and_every_section_reference_exist
|
|||
headings = {int(n) for n in re.findall(r"^## (\d+)\. ", DOC, re.M)}
|
||||
for reference in re.findall(r"§(\d+)", DOC):
|
||||
assert int(reference) in headings, f"§{reference} does not exist"
|
||||
|
||||
|
||||
def test_the_document_no_longer_calls_any_route_untyped():
|
||||
assert "typed: false" not in DOC and "Status: partial" not in DOC
|
||||
assert "WIDGET_HOST_PROTOCOL.md" in DOC and "Idempotency-Key" in DOC
|
||||
|
|
|
|||
|
|
@ -508,18 +508,6 @@ def test_chat_history_does_not_emit_client_surface(tmp_path):
|
|||
# Prompt-fact parity: SYSTEM.md product claims stay bound to the code (AC8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_system_prompt_reload_claim_is_code_bound():
|
||||
system_md = (REPO / "prompts" / "SYSTEM.md").read_text(encoding="utf-8")
|
||||
if "reload-on-SHA" not in system_md and "reloads itself" not in system_md:
|
||||
return # the claim was removed together with its obligation
|
||||
ws_js = (REPO / "web" / "modules" / "ws.js").read_text(encoding="utf-8")
|
||||
assert "sha-change" in ws_js or "Reload on SHA change" in ws_js, (
|
||||
"SYSTEM.md claims the UI auto-reloads on SHA change, but the ws.js "
|
||||
"reload path is gone — the prompt now states a false product fact "
|
||||
"(the exact failure class this feature exists to close)"
|
||||
)
|
||||
|
||||
|
||||
def test_frontend_sends_raw_observables_without_device_taxonomy():
|
||||
chat_js = (REPO / "web" / "modules" / "chat.js").read_text(encoding="utf-8")
|
||||
surface_js = (REPO / "web" / "modules" / "client_surface.js").read_text(encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -1,189 +1,4 @@
|
|||
"""ABI 7.0 reviewer comma-list retirement checks.
|
||||
|
||||
Grep-level checker over ``ouroboros/`` + ``web/`` (+ ``supervisor/``) pinning
|
||||
that the retired reviewer comma-list surface stays retired. Three sweeps:
|
||||
|
||||
1. every textual mention of a retired comma key (the list is snapped
|
||||
DYNAMICALLY from ``RETIRED_COMMA_LIST_SETTING_KEYS`` — a key added to the
|
||||
retirement SSOT is swept automatically) must sit on the count-anchored
|
||||
allowlist of legitimate remnants below;
|
||||
2. comma-``split`` parsing inside model/review modules is count-anchored to
|
||||
the known derived-plane parsers — the review-configuration modules
|
||||
themselves carry NO comma parsing (the structured
|
||||
``OUROBOROS_REVIEWER_SLOTS`` is the one configuration surface);
|
||||
3. removed route plumbing stays removed, and the retired
|
||||
per-row route envs are IGNORED at runtime (retired-envs-are-ignored pin).
|
||||
|
||||
Allowlist discipline follows tests/test_gateway_abi3_removals.py: PER-SITE and
|
||||
COUNT-ANCHORED — a new mention in an allowlisted file breaks the anchor and
|
||||
fails; a stale row (no mention matches) fails too, so the allowlist can only
|
||||
shrink deliberately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
from ouroboros.settings_defaults import RETIRED_COMMA_LIST_SETTING_KEYS
|
||||
|
||||
_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
_SWEEP_BASES = ("ouroboros", "web", "supervisor")
|
||||
_SWEEP_SUFFIXES = (".py", ".js", ".html", ".css")
|
||||
|
||||
|
||||
def _sweep_files():
|
||||
for base in _SWEEP_BASES:
|
||||
for path in sorted((_ROOT / base).rglob("*")):
|
||||
if path.suffix in _SWEEP_SUFFIXES and path.is_file():
|
||||
yield path
|
||||
|
||||
|
||||
# (posix path, retired key) -> (reason, exact mention count).
|
||||
# Every row belongs to one of these legitimate remnant classes:
|
||||
# retirement-SSOT — the list that declares the keys retired;
|
||||
# derived env plane — the comma ENV spellings of the two model lists live on
|
||||
# as the runtime projection for the API-pinned surfaces (never settings);
|
||||
# raw-dict tolerance — normalizers that accept a raw dict fed directly
|
||||
# (tests/tools), ABI-10-commented in place;
|
||||
# retirement prose — comments/docstrings that NAME the key to say it is
|
||||
# retired and ignored.
|
||||
_RETIRED_KEY_MENTION_ALLOWLIST = {
|
||||
# -- retirement SSOT (declares the keys retired; feeds the RC auditor).
|
||||
("ouroboros/settings_defaults.py", "OUROBOROS_REVIEW_MODELS"): ("retirement SSOT", 2),
|
||||
("ouroboros/settings_defaults.py", "OUROBOROS_SCOPE_REVIEW_MODELS"): ("retirement SSOT", 2),
|
||||
("ouroboros/settings_defaults.py", "OUROBOROS_SCOPE_REVIEW_MODEL"): ("retirement SSOT", 2),
|
||||
("ouroboros/settings_defaults.py", "OUROBOROS_REVIEW_ROUTES"): ("retirement SSOT", 2),
|
||||
("ouroboros/settings_defaults.py", "OUROBOROS_SCOPE_REVIEW_ROUTES"): ("retirement SSOT", 2),
|
||||
("ouroboros/settings_defaults.py", "OUROBOROS_ADVISORY_REVIEW_ROUTE"): ("retirement SSOT", 2),
|
||||
# -- raw-dict tolerance: direct-provider and prior-default review/scope
|
||||
# normalization over dicts fed directly (load_settings purges the keys
|
||||
# first; ABI-10-commented).
|
||||
("ouroboros/server_runtime.py", "OUROBOROS_REVIEW_MODELS"): ("raw-dict review normalization", 5),
|
||||
("ouroboros/server_runtime.py", "OUROBOROS_SCOPE_REVIEW_MODELS"): ("raw-dict scope review normalization", 7),
|
||||
("ouroboros/server_runtime.py", "OUROBOROS_SCOPE_REVIEW_MODEL"): ("raw-dict scope review normalization", 7),
|
||||
# -- retirement prose (names the key to say it is retired/ignored).
|
||||
("ouroboros/review_execution.py", "OUROBOROS_REVIEW_ROUTES"): ("retirement prose", 1),
|
||||
("ouroboros/review_execution.py", "OUROBOROS_SCOPE_REVIEW_ROUTES"): ("retirement prose", 1),
|
||||
}
|
||||
|
||||
|
||||
def test_retired_comma_key_mentions_are_allowlisted():
|
||||
keys = sorted(RETIRED_COMMA_LIST_SETTING_KEYS, key=len, reverse=True)
|
||||
assert keys, "retirement SSOT list is empty — the sweep has no subject"
|
||||
observed: Counter = Counter()
|
||||
for path in _sweep_files():
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
rel = path.relative_to(_ROOT).as_posix()
|
||||
for key in keys:
|
||||
# Negative lookahead keeps OUROBOROS_SCOPE_REVIEW_MODEL from also
|
||||
# counting every OUROBOROS_SCOPE_REVIEW_MODELS mention.
|
||||
count = len(re.findall(rf"{key}(?![A-Z_])", text))
|
||||
if count:
|
||||
observed[(rel, key)] = count
|
||||
expected = {site: count for site, (_reason, count) in _RETIRED_KEY_MENTION_ALLOWLIST.items()}
|
||||
new_sites = {site: n for site, n in observed.items() if site not in expected}
|
||||
assert not new_sites, f"NEW retired comma-key mentions (extend or clean): {new_sites}"
|
||||
stale = {site: n for site, n in expected.items() if site not in observed}
|
||||
assert not stale, f"stale allowlist rows (remnant gone — delete the row): {stale}"
|
||||
drifted = {site: (observed[site], expected[site])
|
||||
for site in observed if observed[site] != expected[site]}
|
||||
assert not drifted, f"mention count drifted (observed, allowlisted): {drifted}"
|
||||
|
||||
|
||||
# posix path -> (reason, exact count of comma-split call sites). The review
|
||||
# configuration modules themselves must stay comma-split-free.
|
||||
_COMMA_SPLIT_ALLOWLIST = {
|
||||
"ouroboros/model_slots.py": ("generic model-list parser (OUROBOROS_MODEL_FALLBACKS et al.)", 1),
|
||||
"ouroboros/provider_models.py": ("declared-model list parser over the derived plane", 1),
|
||||
"ouroboros/gateway/models.py": ("Provider Test deterministic-model resolver", 1),
|
||||
"web/modules/model_roles.js": ("existing ordered fallback model setting, never reviewer configuration", 1),
|
||||
}
|
||||
|
||||
|
||||
def _count_comma_split_calls_py(text: str) -> int:
|
||||
"""AST-level count of ``<expr>.split(",")`` / ``.rsplit(",")`` calls —
|
||||
positional or keyword extras (``maxsplit=``), spacing and quote style
|
||||
cannot evade it (the setattr-scan lesson: a parser gate scans SYNTAX,
|
||||
not one string spelling). The separator may arrive as EITHER of the
|
||||
first two positionals (bound ``s.split(",")`` / unbound
|
||||
``str.split(raw, ",")``) or the ``sep=`` keyword regardless of
|
||||
positional count (unbound ``str.split(raw, sep=",")``)."""
|
||||
tree = ast.parse(text)
|
||||
count = 0
|
||||
for node in ast.walk(tree):
|
||||
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
|
||||
continue
|
||||
if node.func.attr not in ("split", "rsplit"):
|
||||
continue
|
||||
sep_candidates = list(node.args[:2]) + [
|
||||
kw.value for kw in node.keywords if kw.arg == "sep"
|
||||
]
|
||||
if any(
|
||||
isinstance(candidate, ast.Constant) and candidate.value == ","
|
||||
for candidate in sep_candidates
|
||||
):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def test_comma_split_model_parsing_is_allowlisted():
|
||||
observed: Counter = Counter()
|
||||
for path in _sweep_files():
|
||||
rel = path.relative_to(_ROOT).as_posix()
|
||||
if not re.search(r"model|review", rel):
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if path.suffix == ".py":
|
||||
count = _count_comma_split_calls_py(text)
|
||||
else:
|
||||
# Non-Python mirrors keep the textual scan (no Python AST there).
|
||||
count = len(re.findall(r"""split\(\s*(?:","|',')\s*""", text))
|
||||
if count:
|
||||
observed[rel] = count
|
||||
expected = {rel: count for rel, (_reason, count) in _COMMA_SPLIT_ALLOWLIST.items()}
|
||||
assert dict(observed) == expected, (
|
||||
f"comma-split drift in model/review modules: observed={dict(observed)}, "
|
||||
f"allowlisted={expected}"
|
||||
)
|
||||
|
||||
|
||||
def test_comma_split_ast_scan_sees_the_evasion_spellings():
|
||||
"""Self-test of the gate's detector: the plain-syntax evasions the string
|
||||
regexp missed are counted, and a non-comma split is not."""
|
||||
assert _count_comma_split_calls_py('raw.split(",", maxsplit=-1)') == 1
|
||||
assert _count_comma_split_calls_py("raw.split( ',' )") == 1
|
||||
assert _count_comma_split_calls_py("raw.rsplit(',')") == 1
|
||||
assert _count_comma_split_calls_py('raw.split(sep=",")') == 1
|
||||
# Unbound-method evasions (fix-round 2, claim 7): positional args present
|
||||
# AND the separator hiding in sep=, or riding as the second positional.
|
||||
assert _count_comma_split_calls_py('str.split(raw, sep=",")') == 1
|
||||
assert _count_comma_split_calls_py('str.split(raw, ",")') == 1
|
||||
assert _count_comma_split_calls_py('str.rsplit(raw, ",")') == 1
|
||||
assert _count_comma_split_calls_py('raw.split(";")') == 0
|
||||
assert _count_comma_split_calls_py("raw.split()") == 0
|
||||
assert _count_comma_split_calls_py("str.split(raw)") == 0
|
||||
|
||||
|
||||
def test_phase5_route_plumbing_stays_removed():
|
||||
"""Removed route plumbing stays removed: no per-row route env plumbing, no
|
||||
advisory route env constant, anywhere under the swept trees."""
|
||||
retired_symbols = (
|
||||
"configured_review_routes",
|
||||
"TRIAD_REVIEW_ROUTES_ENV",
|
||||
"SCOPE_REVIEW_ROUTES_ENV",
|
||||
"ADVISORY_REVIEW_ROUTE_ENV",
|
||||
"route_env_key",
|
||||
)
|
||||
hits = []
|
||||
for path in _sweep_files():
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
for symbol in retired_symbols:
|
||||
if re.search(rf"\b{symbol}\b", text):
|
||||
hits.append(f"{path.relative_to(_ROOT).as_posix()}: {symbol}")
|
||||
assert not hits, f"phase-5 route plumbing remnants: {hits}"
|
||||
"""Retired reviewer route environment keys cannot override the catalog pool."""
|
||||
|
||||
|
||||
def test_retired_route_envs_are_ignored(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -1,19 +1,9 @@
|
|||
"""ABI 7.0 (ABI-10, owner 5.4=A): the comma-list remnant sweep — the phase CI gate.
|
||||
|
||||
Grep-level checker pinning that the legacy reviewer comma-list migration read
|
||||
stays gone: no migration-read branches, the settings vocabulary carries the
|
||||
comma keys only as RETIRED, and no bench settings template configures
|
||||
reviewers through them. Live reviews read the catalog pool; historical raw-dict
|
||||
normalizers may still name the comma keys. The sweep pins SETTINGS-plane and
|
||||
migration-branch absence, while test_comma_list_remnant_sweep owns the textual
|
||||
remnant allowlist.
|
||||
"""
|
||||
"""Current review-pool states and compatibility with retired comma-list settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
|
@ -29,32 +19,11 @@ _PHASE5_ROUTE_KEYS = (
|
|||
)
|
||||
|
||||
|
||||
def _python_files() -> list[pathlib.Path]:
|
||||
files: list[pathlib.Path] = []
|
||||
for base in ("ouroboros", "supervisor"):
|
||||
files.extend((_ROOT / base).rglob("*.py"))
|
||||
return files
|
||||
|
||||
|
||||
def test_no_migration_read_branches_remain():
|
||||
"""The reviewer_slot_config legacy block and its helpers stay deleted."""
|
||||
retired_symbols = ("_legacy_rows", "_legacy_config", "_shared_session_route_spec")
|
||||
hits: list[str] = []
|
||||
for path in _python_files():
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
for symbol in retired_symbols:
|
||||
if re.search(rf"\b{symbol}\b", text):
|
||||
hits.append(f"{path.relative_to(_ROOT)}: {symbol}")
|
||||
assert not hits, f"migration-read remnants: {hits}"
|
||||
|
||||
|
||||
def test_review_pool_state_vocabulary_is_structured_empty_error():
|
||||
"""The pool's state vocabulary is structured|empty|error — never ``legacy``."""
|
||||
from ouroboros.reviewer_slot_config import review_pool_state
|
||||
from tests.review_pool_rosters import pool_roster, pool_seat
|
||||
|
||||
text = (_ROOT / "ouroboros" / "reviewer_slot_config.py").read_text(encoding="utf-8")
|
||||
assert 'source="legacy"' not in text and '"legacy"' not in text
|
||||
assert review_pool_state(None)["state"] == "empty"
|
||||
assert review_pool_state(pool_roster(pool_seat("a", "m/one")))["state"] == "structured"
|
||||
assert review_pool_state("{not json")["state"] == "error"
|
||||
|
|
@ -101,23 +70,3 @@ def test_no_bench_settings_template_configures_reviewers_via_comma_keys():
|
|||
if key in data:
|
||||
hits.append(f"{path.relative_to(_ROOT)}: {key}")
|
||||
assert not hits, f"bench templates still carry retired comma keys: {hits}"
|
||||
|
||||
|
||||
def test_prose_no_longer_promises_a_comma_list_migration():
|
||||
"""The rewritten prose markers stay rewritten (review.py:965 class)."""
|
||||
for rel in ("ouroboros/tools/review.py", "ouroboros/reviewer_slot_config.py",
|
||||
"ouroboros/review_model_routes.py"):
|
||||
text = (_ROOT / rel).read_text(encoding="utf-8")
|
||||
assert "migrated comma-lists" not in text, rel
|
||||
assert "старый читается" not in text, rel
|
||||
|
||||
|
||||
def test_the_derived_projection_left_with_the_lanes():
|
||||
"""5.4=A removed the migration READ; PR-3 removed the lane projection too — the
|
||||
comma plane has no writer, and ``apply_settings_to_env`` projects no floor."""
|
||||
import inspect
|
||||
|
||||
from ouroboros import config, reviewer_slot_config
|
||||
|
||||
assert not hasattr(reviewer_slot_config, "project_reviewer_slots_into_env")
|
||||
assert "project_reviewer_slots_into_env" not in inspect.getsource(config.apply_settings_to_env)
|
||||
|
|
|
|||
|
|
@ -318,16 +318,6 @@ def test_valid_capsule_recompacts_and_corrupt_capsule_stays_raw(monkeypatch, tmp
|
|||
assert cc._atomic_units(corrupt_hash) == ()
|
||||
|
||||
|
||||
def test_legacy_omission_api_is_deleted():
|
||||
source = cc.pathlib.Path(cc.__file__).read_text(encoding="utf-8")
|
||||
for forbidden in (
|
||||
"_SUMMARY_INPUT_LIMIT", "_excerpt_for_summary", "_compact_argument_value",
|
||||
"LONG_STRING", "CONTENT_OMITTED", "def compact_tool_history(",
|
||||
"_round_has_protected_content", "_tool_round_spans",
|
||||
):
|
||||
assert forbidden not in source
|
||||
|
||||
|
||||
def test_manual_compact_context_copy_is_truthful_about_summary_and_provenance():
|
||||
from ouroboros.tools.compact_context import _compact_context, get_tools
|
||||
|
||||
|
|
|
|||
|
|
@ -322,16 +322,7 @@ def test_an_unreadable_ledger_refuses_the_start_instead_of_assuming_free(data_ro
|
|||
|
||||
|
||||
def test_the_wake_prompt_money_framing_matches_what_the_ledger_actually_records():
|
||||
"""The prompt's three money words are the reducer's, not a separate vocabulary."""
|
||||
import pathlib
|
||||
|
||||
prompt = (pathlib.Path(__file__).resolve().parent.parent / "prompts" / "CONSCIOUSNESS.md").read_text(
|
||||
encoding="utf-8")
|
||||
assert "Distinguish incremental cash cost from subscription quota and time." in prompt
|
||||
assert "A confirmed zero-cost call spends no cash; an undisclosed cost is unknown." in prompt
|
||||
assert "Existing limits still apply." in prompt
|
||||
# A confirmed zero and an undisclosed price are DIFFERENT ledger rows, which is
|
||||
# exactly the distinction the sentence above asks this mind to make.
|
||||
"""The reducer distinguishes free calls, unknown prices, and observed quota."""
|
||||
free = {"kind": "subscription_session", "state": "settled", "cost_usd": 0.0,
|
||||
"cost_final": True, "pricing_known": True}
|
||||
undisclosed = {"kind": "subscription_session", "state": "settled", "cost_usd": None,
|
||||
|
|
@ -340,8 +331,7 @@ def test_the_wake_prompt_money_framing_matches_what_the_ledger_actually_records(
|
|||
|
||||
assert _summary([free])["accounted_usd"] == 0.0 and _summary([free])["unknown_unmetered"] == 0
|
||||
assert _summary([undisclosed])["unknown_unmetered"] == 1
|
||||
# The allowance is the only budget the prompt promises; there is no quota one.
|
||||
assert set(allowance.allowance_window.__doc__.split()) >= {"allowance"}
|
||||
# The allowance is the only budget; observed quota is not another budget.
|
||||
assert not hasattr(allowance, "quota_window")
|
||||
# Time and quota stay separate OBSERVED axes of the same reducer, never budgets.
|
||||
session = _summary([{**free, "subscription_route": "r", "subscription_reset_at": "2026-09-17T00:00:00Z"}])
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ block.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
|
@ -18,18 +17,6 @@ from ouroboros.context import build_runtime_section, build_user_content
|
|||
|
||||
from tests._context_shared import _make_health_env
|
||||
|
||||
def test_build_llm_messages_has_no_recorder_only_soft_cap_chain():
|
||||
from ouroboros import context as context_module
|
||||
from ouroboros.context import build_llm_messages
|
||||
|
||||
assert "soft_cap_tokens" not in inspect.signature(build_llm_messages).parameters
|
||||
assert not hasattr(context_module, "apply_message_token_soft_cap")
|
||||
source = inspect.getsource(build_llm_messages)
|
||||
assert "estimated_tokens_before" not in source
|
||||
assert "trimmed_sections" not in source
|
||||
assert "context_fit" in source
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enforcement", ["blocking", "advisory"])
|
||||
def test_force_plan_metadata_adds_structured_notice_without_rewriting_user_text(
|
||||
monkeypatch, enforcement,
|
||||
|
|
|
|||
|
|
@ -830,28 +830,7 @@ def test_clb_operator_patches_are_tracked_with_an_unambiguous_apply_order():
|
|||
assert [p.name for p in required] == ["base_url", "repo_dir"]
|
||||
assert all(p.default is inspect.Parameter.empty for p in required)
|
||||
assert "runtime_attestation(self._server.base_url, pathlib.Path(clone)," in attest
|
||||
# ...and the signature the patch DOCUMENTS must name it too, or the next reader copies
|
||||
# the broken call back out of the docstring.
|
||||
assert "runtime_attestation(base_url, repo_dir, *, expected_version=" in attest
|
||||
assert "OBO_ALLOW_EVOLVED_VOLUME" in attest
|
||||
readme = (patches / "README.md").read_text(encoding="utf-8")
|
||||
# The stack order must name the three predecessor bridge patches explicitly.
|
||||
for name in ("run_clbench_bridge_agent.v6560.patch", "clb_acceptance_claims.v674.patch",
|
||||
"clb_disabled_tools_env.v6745.patch"):
|
||||
assert name in readme
|
||||
order = readme.index("Apply order (unambiguous)")
|
||||
assert order < readme.index("clb_multi_instance_outcomes.v6746.patch")
|
||||
|
||||
|
||||
def test_clb_methodology_states_poker_instance_count_and_outcome_semantics():
|
||||
text = (REPO_ROOT / "devtools" / "benchmarks" / "continual_learning"
|
||||
/ "METHODOLOGY.md").read_text(encoding="utf-8")
|
||||
assert "`exploitable_poker` = 120" in text
|
||||
assert "--num-instances 120" in text
|
||||
assert "auto_resolved_no_agent_turn" in text
|
||||
assert "Accepted overshoot (owner decision)" in text
|
||||
assert "late_instance_outcomes_fill" in text
|
||||
assert "Disclosed residual" in text
|
||||
|
||||
|
||||
def test_collect_results_normalizes_bridge_traces(tmp_path):
|
||||
|
|
|
|||
|
|
@ -6,61 +6,6 @@ from ouroboros.tools.scope_review_contract import COUPLING_QUESTION_IDS
|
|||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_public_contributor_flow_is_agent_first_and_route_neutral():
|
||||
guide = (ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8")
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
assert "against lowercase `ouroboros`, not `main` or `ouroboros-stable`" in guide
|
||||
assert "Coding agents and people must read [CONTRIBUTING.md]" in readme
|
||||
for path in (
|
||||
"BIBLE.md", "docs/ARCHITECTURE.md", "docs/DEVELOPMENT.md",
|
||||
"docs/CHECKLISTS.md",
|
||||
):
|
||||
assert path in guide
|
||||
# 11=A: only the checklist SSOT is read cover-to-cover; the other project
|
||||
# documents are mapped by headings with relevant sections read in full.
|
||||
assert "[`docs/CHECKLISTS.md`](docs/CHECKLISTS.md)" in guide
|
||||
assert "**in full**" in guide
|
||||
assert "navigation map" in guide
|
||||
assert "read every section relevant" in guide
|
||||
# Negative pins: the retired blanket contract must not resurface anywhere
|
||||
# in the two entry documents (README routes contributors here).
|
||||
assert "read these files **in full**" not in guide
|
||||
assert "The required project documents were read in full" not in guide
|
||||
assert "in full before editing" not in readme.replace(
|
||||
"reading every section relevant to your change in full before editing", "")
|
||||
# 10=A: the agentic checklist review is the main path and must cover every
|
||||
# coupling item with the runtime output contract.
|
||||
assert "agentic checklist review" in guide
|
||||
for item in COUPLING_QUESTION_IDS:
|
||||
assert item in guide
|
||||
assert "scripts/validate_scope_receipt.py" in guide
|
||||
assert "separate agent context" in guide
|
||||
assert "Reviewing in the authoring conversation does not count" in guide
|
||||
assert "Mark the review `NOT_RUN`" in guide
|
||||
# The script lane states what a scope reviewer is owed (a change-relative
|
||||
# required-source floor) instead of a window requirement (#395).
|
||||
assert "maintainer tooling" in guide
|
||||
assert "SCOPE_REVIEW_BLOCKED" in guide
|
||||
assert "change-relative" in guide
|
||||
assert "does not depend on a very large reviewer window" in guide
|
||||
assert "Reading coverage is diagnostic" in guide
|
||||
assert "never remove a responding reviewer from quorum" in guide
|
||||
assert "SHAPE, not truth" in guide
|
||||
assert "--contributor" in guide
|
||||
assert "--base-ref upstream/ouroboros" in guide
|
||||
# D31: the command runs from a clean checkout of the target base (the installed
|
||||
# body) and names the proposal; the proposal is never that checkout's HEAD.
|
||||
assert "git worktree add --detach ../ouroboros-review upstream/ouroboros" in guide
|
||||
assert "--head-ref <your-branch>" in guide
|
||||
assert "--head-ref HEAD" not in guide
|
||||
assert "installed body's review flow and rules" in guide
|
||||
assert "re-runs itself" not in guide
|
||||
assert "review-packet.zip" in guide
|
||||
assert "evidence, not a promise to merge" in guide
|
||||
assert "OpenRouter" not in guide
|
||||
|
||||
|
||||
def test_pull_request_template_has_one_universal_agent_review_block():
|
||||
template = (ROOT / ".github" / "PULL_REQUEST_TEMPLATE.md").read_text(
|
||||
encoding="utf-8"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Pure structural checks for the CyberGym benchmark profile and documentation.
|
||||
"""Pure structural checks for the CyberGym benchmark profile.
|
||||
|
||||
These tests intentionally do not import CyberGym, Docker, inspect, browser, or
|
||||
any other optional evaluator dependency. They validate the repository-owned
|
||||
|
|
@ -12,7 +12,6 @@ from pathlib import Path
|
|||
|
||||
from devtools.benchmarks.common import launcher_audit
|
||||
from ouroboros.configured_subagents import parse_configured_subagents
|
||||
from tests._governance_docs_shared import architecture_text
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
PROFILE = REPO / "devtools" / "benchmarks" / "cybergym" / "settings_base.json"
|
||||
|
|
@ -132,61 +131,3 @@ def test_cybergym_is_registered_and_passes_the_shared_structural_audit():
|
|||
assert relative in launcher_audit.MIGRATED_LAUNCHERS
|
||||
assert path in launcher_audit.launcher_paths()
|
||||
assert launcher_audit.audit_launcher(path) == []
|
||||
|
||||
|
||||
def test_benchmark_inventory_points_to_cybergym_docs():
|
||||
common_readme = (REPO / "devtools" / "benchmarks" / "README.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
architecture = architecture_text(REPO)
|
||||
assert "cybergym/" in common_readme
|
||||
assert "devtools/benchmarks/cybergym/" in architecture
|
||||
|
||||
|
||||
def test_cybergym_docs_pin_the_owner_approved_contract():
|
||||
readme = (PROFILE.parent / "README.md").read_text(encoding="utf-8")
|
||||
methodology = (PROFILE.parent / "METHODOLOGY.md").read_text(encoding="utf-8")
|
||||
combined = f"{readme}\n{methodology}"
|
||||
required = (
|
||||
MODEL,
|
||||
"7656b71d07da6694e262f9c34ea994cd4849c0eb",
|
||||
"bde190ded494e52bc684b66073b436c9d992c7c6",
|
||||
"9cea452cc1e1a3703e0f60c2dfc8642430aab9f50433f976581509de58c7048f",
|
||||
"1,507",
|
||||
"Level 1",
|
||||
"final.poc",
|
||||
"any-of",
|
||||
"raw_vul_exit_code not in {0, 71, 300}",
|
||||
"raw_fix_exit_code == 0",
|
||||
"build_isolated_settings",
|
||||
"OUROBOROS_OR_PROVIDER",
|
||||
"only",
|
||||
"order",
|
||||
"cybergym-internal",
|
||||
"Internal=false",
|
||||
"unrestricted outbound",
|
||||
"mandatory trajectory audit",
|
||||
"issue tracker or bug reports",
|
||||
"ready-made PoC",
|
||||
"rootless",
|
||||
"Docker `--network host`",
|
||||
"OUROBOROS_MAX_SUBAGENT_DEPTH=0",
|
||||
"schedule_subagent",
|
||||
"delegate_start",
|
||||
"claude_code_edit",
|
||||
"OUROBOROS_TASK_ABS_CEILING_SEC=21600",
|
||||
"USD 3,000",
|
||||
"six hours",
|
||||
"admit_benchmark_run",
|
||||
"finalize_run_manifest",
|
||||
"append-only",
|
||||
"cleanup",
|
||||
"verbatim",
|
||||
"workspace_custody_timeout",
|
||||
)
|
||||
for phrase in required:
|
||||
assert phrase in combined, phrase
|
||||
assert "template" in readme.lower()
|
||||
assert "applied" in readme.lower()
|
||||
assert "leaderboard" in readme.lower()
|
||||
assert "workspace_custody_timeout" in readme and "verbatim" in readme
|
||||
|
|
|
|||
|
|
@ -1188,44 +1188,6 @@ def test_delegate_schemas_teach_the_escalation_verb():
|
|||
assert "escalate to your human" not in schemas[name]
|
||||
|
||||
|
||||
def test_expiry_notes_teach_the_escalation_verb():
|
||||
"""Delta finding: the REPEAT delegate_wait rides the expiry path, whose
|
||||
notes are decision points too — both branches name the escalate verb and
|
||||
never the retired progress/your-human dead ends."""
|
||||
import inspect
|
||||
|
||||
import ouroboros.delegate_progress as dp
|
||||
|
||||
source = inspect.getsource(dp)
|
||||
assert "escalate an above-authority question with the" in source
|
||||
assert "raise it with the escalate verb (parent-first)" in source
|
||||
assert "escalate to your human via a progress message" not in source
|
||||
assert "escalate to your human," not in source
|
||||
# The dispatch charter is an LLM-facing user message too (agent.py appends
|
||||
# it): the same dead end must not survive there. The campaign owner of
|
||||
# dispatch_executor_note is agent_dispatch (subagent_dispatch_notes is the
|
||||
# historical import facade).
|
||||
import ouroboros.agent_dispatch as dn
|
||||
|
||||
charter = inspect.getsource(dn)
|
||||
assert "escalated with the escalate verb (parent-first" in charter
|
||||
assert "goes to your human via progress" not in charter
|
||||
# No delegated-question surface may teach direct-to-human ESCALATION: the
|
||||
# hierarchy (parent-first) is the ONLY route (decision 31). Semantic
|
||||
# variants of the escalation phrasing, not just the exact retired lines
|
||||
# (the live progress STREAM legitimately mentions the human).
|
||||
import ouroboros.tools.delegate as dtool
|
||||
|
||||
import ouroboros.delegate_interactions as di
|
||||
|
||||
for module in (dp, dn, di, dtool):
|
||||
text = inspect.getsource(module)
|
||||
for phrase in ("escalated to its human", "escalated to your human",
|
||||
"escalated a question to its human",
|
||||
"goes to your human", "surface it to your human"):
|
||||
assert phrase not in text, (module.__name__, phrase)
|
||||
|
||||
|
||||
def test_definite_answer_refusals_publish_as_recorded_refusals(tmp_path, monkeypatch):
|
||||
"""Owner Q8A: a refusal the engine confirmed (rejected rows) is an agent fault,
|
||||
an absent run a substrate refusal; neither is an OK observation."""
|
||||
|
|
|
|||
|
|
@ -706,18 +706,6 @@ def test_the_terminal_payload_carries_resumable_and_one_line_per_continued_try()
|
|||
|
||||
# --------------------------------------------------------------------------- unchanged neighbours
|
||||
|
||||
@pytest.mark.parametrize("chapter,marker,codes", [
|
||||
("development/06-rules-by-change-class.md", "- `continue_from`", (
|
||||
continuation.REFUSAL_SNAPSHOT_MISSING, continuation.REFUSAL_SNAPSHOT_RELEASED,
|
||||
"continuation_handover_busy")),
|
||||
("architecture/06-agent-core.md", "**Continuation**", (continuation.REFUSAL_SNAPSHOT_MISSING,)),
|
||||
])
|
||||
def test_continuation_docs_name_the_snapshot_custody_refusals(chapter, marker, codes):
|
||||
text = (pathlib.Path(__file__).resolve().parents[1] / "docs" / chapter).read_text(encoding="utf-8")
|
||||
paragraph = text[text.index(marker):].split("\n\n", 1)[0]
|
||||
assert all(f"`{code}`" in paragraph for code in codes)
|
||||
assert "refuses ONLY on the four floors" not in paragraph
|
||||
|
||||
|
||||
def test_requested_cap_is_clamped_by_the_deadline_and_the_remaining_lifetime(tmp_path, monkeypatch):
|
||||
"""The cap basis is still RECORDED beside the number (a fact for the parent),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ real ``read_file`` stamp, never a helper call standing in for them:
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import pathlib
|
||||
from dataclasses import replace
|
||||
|
|
@ -145,11 +144,6 @@ def test_main_runtime_data_read_of_the_same_exact_source_is_credited(main_loop,
|
|||
def test_every_read_enters_the_one_acknowledgement_once(geo, monkeypatch):
|
||||
"""No second hook, no prefix estimate: a direct read and an unrelated read each
|
||||
call the single acknowledgement owner once; only the exact source credits."""
|
||||
from ouroboros.tools import core_file_tools
|
||||
|
||||
source = inspect.getsource(core_file_tools)
|
||||
assert source.count("acknowledge_staged_output") == 2, "one import + one call in _stamp_read_view"
|
||||
assert "tool_result_limit" not in inspect.getsource(output._acknowledge_staged_output)
|
||||
ctx = _top(geo, ROOT)
|
||||
read_next = _stage(geo, ctx, monkeypatch)["output_delivery"]["read_next"]
|
||||
calls = []
|
||||
|
|
|
|||
|
|
@ -460,7 +460,7 @@ def test_capture_transports_utf8_with_nul_and_loader_junk_stays_out(tmp_path, mo
|
|||
entry = _payload_entry(handle, skill)
|
||||
capture = _capture_terminal_patch(ctx, entry)
|
||||
assert capture["status"] == "ready_with_changes", capture
|
||||
manifest = json.loads(pathlib.Path(capture["manifest_artifact"]).read_text())
|
||||
manifest = json.loads(pathlib.Path(capture["manifest_artifact"]).read_text(encoding="utf-8"))
|
||||
assert manifest["capture_kind"] == "skill_payload"
|
||||
assert set(manifest["tracked_changed"]) == {"table.txt", "plugin.py", "notes.txt"}
|
||||
assert manifest["blocked_reserved_paths"] == []
|
||||
|
|
@ -576,7 +576,7 @@ def test_reserved_path_patch_refuses_whole_apply_and_preserves_candidate(tmp_pat
|
|||
|
||||
ctx, skill, handle, entry, capture = _captured(tmp_path, monkeypatch, edit=edit)
|
||||
assert capture["status"] == "ready_with_changes", capture
|
||||
manifest = json.loads(pathlib.Path(capture["manifest_artifact"]).read_text())
|
||||
manifest = json.loads(pathlib.Path(capture["manifest_artifact"]).read_text(encoding="utf-8"))
|
||||
assert manifest["blocked_reserved_paths"] == [".clawhub.json"]
|
||||
out = _integrate_delegated_patch(ctx, "run-p1", "apply", "")
|
||||
assert "INTEGRATE_DELEGATED_RESERVED_PATHS" in out, out
|
||||
|
|
@ -884,18 +884,15 @@ def test_reconcile_queue_failure_degrades_the_receipt_honestly(tmp_path, monkeyp
|
|||
custody._CUSTODY.clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("iteration", range(50))
|
||||
def test_parallel_starts_on_same_payload_yield_exactly_one_winner(tmp_path, monkeypatch, iteration):
|
||||
"""Gate fix 5 + Sol delta 5a: deterministic REQUESTED→STARTED window, x50.
|
||||
def test_parallel_starts_on_same_payload_yield_exactly_one_winner(tmp_path, monkeypatch):
|
||||
"""Gate fix 5 + Sol delta 5a: one deterministic REQUESTED→STARTED window.
|
||||
|
||||
Both starts pass the cheap early check and provision; the winner then claims
|
||||
(durable START_REQUESTED) and is HELD inside the gateway POST — after its
|
||||
request row, before its STARTED row. The loser runs its locked busy predicate
|
||||
strictly inside that window: the old two-pass read could miss a holder whose
|
||||
transition landed between the passes; the single-pass snapshot cannot. Fifty
|
||||
fresh-drive cases prove the interleaving is stable, not schedule-lucky.
|
||||
In the parallel lane each repeat has its own pytest timeout; repeated real
|
||||
provisioning and store imports must not consume one repeat's hang budget.
|
||||
transition landed between the passes; the single-pass snapshot cannot. The
|
||||
barrier and events force that ordering, so repetition adds no new schedule.
|
||||
(Both starts are one task here; distinct owners: tests/test_custody_keyed_readers.py.)"""
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
|
|
@ -909,7 +906,7 @@ def test_parallel_starts_on_same_payload_yield_exactly_one_winner(tmp_path, monk
|
|||
real_provision = integration._provision_payload_snapshot
|
||||
real_snapshot, real_open = current.payload_snapshot, store.open_store
|
||||
inside = threading.local()
|
||||
base = tmp_path / f"iter{iteration}"
|
||||
base = tmp_path / "race"
|
||||
base.mkdir()
|
||||
ctx = _payload_ctx(base, monkeypatch)
|
||||
_seed_skill(base / "data")
|
||||
|
|
@ -950,7 +947,7 @@ def test_parallel_starts_on_same_payload_yield_exactly_one_winner(tmp_path, monk
|
|||
# the busy snapshot's read transaction closed and BEFORE its
|
||||
# projections are built — the exact hole a two-pass (re-reading)
|
||||
# predicate had. Only one shared read still reports busy here.
|
||||
fired.append(iteration)
|
||||
fired.append(True)
|
||||
release.set()
|
||||
thread.join(timeout=30)
|
||||
|
||||
|
|
@ -969,16 +966,17 @@ def test_parallel_starts_on_same_payload_yield_exactly_one_winner(tmp_path, monk
|
|||
|
||||
thread = threading.Thread(target=_winner, name="winner")
|
||||
thread.start()
|
||||
loser = json.loads(_exact_payload_start(
|
||||
ctx, "start loser", root="skill_payload", bucket="external",
|
||||
skill_name="alpha"))
|
||||
release.set()
|
||||
thread.join(timeout=60)
|
||||
assert not thread.is_alive(), f"iteration {iteration}: winner hung"
|
||||
assert loser["status"] == "refused", (iteration, loser)
|
||||
assert loser["reason"] == "payload_delegation_busy", (iteration, loser)
|
||||
assert winner_out and winner_out[0]["status"] == "started", (iteration, winner_out)
|
||||
assert fired == [iteration], f"iteration {iteration}: the window hook never ran"
|
||||
try:
|
||||
loser = json.loads(_exact_payload_start(
|
||||
ctx, "start loser", root="skill_payload", bucket="external", skill_name="alpha"))
|
||||
finally:
|
||||
release.set()
|
||||
thread.join(timeout=60)
|
||||
assert not thread.is_alive(), "winner hung"
|
||||
assert loser["status"] == "refused", loser
|
||||
assert loser["reason"] == "payload_delegation_busy", loser
|
||||
assert winner_out and winner_out[0]["status"] == "started", winner_out
|
||||
assert fired == [True], "the window hook never ran"
|
||||
custody._CUSTODY.clear()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3439,24 +3439,6 @@ def test_gaia_epistemic_instruction_shape_and_all_solvers():
|
|||
assert audit._strip_prompt_boilerplate("Q." + GAIA_EPISTEMIC_INSTRUCTION).strip() == "Q."
|
||||
|
||||
|
||||
def test_epistemic_rule_stays_out_of_the_global_system_prompt():
|
||||
"""Owner Q20/Q22 scoped the rule to the GAIA adapter ONLY: no global grounding duty in
|
||||
`prompts/SYSTEM.md` (it would push the runtime into searching for trivia) and no typed
|
||||
contract field. This is the invariant that keeps a future 'while we are here' edit honest."""
|
||||
system_md = (REPO_ROOT / "prompts" / "SYSTEM.md").read_text(encoding="utf-8").lower()
|
||||
for banned in (
|
||||
"epistemic honesty",
|
||||
"source your external claims",
|
||||
"source your claims",
|
||||
"cite a primary source",
|
||||
"check it against a primary source",
|
||||
):
|
||||
assert banned not in system_md, f"SYSTEM.md must not carry the GAIA grounding rule: {banned}"
|
||||
|
||||
contracts = (REPO_ROOT / "ouroboros" / "contracts" / "task_contract.py").read_text(encoding="utf-8")
|
||||
assert "epistemic" not in contracts.lower(), "Q20/Q22 explicitly rejected a typed contract field"
|
||||
|
||||
|
||||
def test_gaia_claude_code_solver_uses_stream_json_and_writes_trace(monkeypatch, tmp_path):
|
||||
from devtools.benchmarks.gaia.inspect_solver import claude_code_solver as cc
|
||||
|
||||
|
|
@ -3558,7 +3540,6 @@ def test_gaia_audit_gold_verbatim_alone_is_weak_only(tmp_path):
|
|||
assert audit._distinctive_gold(gold)
|
||||
|
||||
|
||||
|
||||
def test_gaia_score_leakage_adjusted(tmp_path):
|
||||
from devtools.benchmarks.gaia import score_gaia
|
||||
|
||||
|
|
@ -3700,24 +3681,6 @@ def test_gaia_distinct_same_basename_declarations_both_stage(tmp_path, monkeypat
|
|||
assert contents == [b"/shared_files/a/doc.pdf", b"/shared_files/b/doc.pdf"]
|
||||
|
||||
|
||||
def test_programbench_instruction_states_tree_ships_as_is():
|
||||
"""v6.74.4: the PB instruction must carry the true submission model (live
|
||||
tree, .git dropped, uncommitted edits ship) and the final compile.sh check,
|
||||
and must no longer claim a fresh checkout."""
|
||||
template = " ".join((
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "devtools" / "benchmarks" / "programbench" / "instruction_template.md"
|
||||
).read_text(encoding="utf-8").split())
|
||||
assert "CURRENT state of your working tree" in template
|
||||
assert "uncommitted edits DO ship" in template
|
||||
assert "The exporter also excludes" in template
|
||||
assert "`.ouroboros/`" in template and "at ANY depth" in template
|
||||
assert "run `./compile.sh` one final time" in template
|
||||
# The negated truth stays; the old false claim must be gone.
|
||||
assert "not from a fresh checkout" in template
|
||||
assert "on a fresh checkout" not in template
|
||||
|
||||
|
||||
def test_programbench_submission_tarball_contract(tmp_path):
|
||||
"""v6.74.4 (codex finding 1): the instruction's submission model must match
|
||||
the exporter — uncommitted source ships from the LIVE tree; .git, root
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import os
|
|||
import pathlib
|
||||
import tempfile
|
||||
import pytest
|
||||
from tests._governance_docs_shared import governance_doc_text
|
||||
from tests.test_swarm_host_admission import host as _promotion_host
|
||||
from tests._store_shared import seed_result, unreadable_result
|
||||
|
||||
|
|
@ -93,18 +92,6 @@ def test_max_mode_inlines_architecture_and_development_in_full():
|
|||
assert "## DEVELOPMENT.md" in text
|
||||
|
||||
|
||||
def test_plan_review_docs_pin_honest_continuation_from_retained_sources():
|
||||
"""A plan participant continues only from its own retained delivery and reply; an
|
||||
unreadable source is a fresh send the ask row discloses (`continued`), never a borrowed
|
||||
transcript. Both books state it; the retired engine's refusal code is not live text."""
|
||||
repo = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
for relative in ("docs/ARCHITECTURE.md", "docs/DEVELOPMENT.md"):
|
||||
text = governance_doc_text(relative, repo)
|
||||
assert "an unreadable source means a fresh send" in text and "`continued`" in text, relative
|
||||
assert "plan_review_exact_artifact_unavailable" not in text, relative
|
||||
|
||||
|
||||
def test_forked_task_captures_canonical_memory_and_exact_api_context():
|
||||
import json
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
"""Guardrails for architecture docs after UI/routing overhaul.
|
||||
|
||||
README prose pins were retired in v5.8.3-rc.5 — the README is intentionally
|
||||
allowed to evolve its marketing copy without dragging tests along; the
|
||||
ARCHITECTURE.md pins below are the load-bearing rationale-layer guards
|
||||
(P6) that must survive every doc-touch commit.
|
||||
"""
|
||||
"""Reference-book structure, executable inventories and prompt identifiers."""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
|
|
@ -56,19 +50,6 @@ def test_component_basename_does_not_borrow_another_explicit_path():
|
|||
assert not _names_basename("test_s3_task_control_browser.py", "browser.py")
|
||||
|
||||
|
||||
def test_the_domain_manifest_is_reachable_from_the_handbook():
|
||||
"""The domain SSOT and its generated map were reachable from neither doc.
|
||||
|
||||
A contributor is told to read DEVELOPMENT.md before moving code, and moving
|
||||
code across domains is exactly what `ouroboros/domains.toml` gates. With no
|
||||
pointer, the first they learned of it was a red `check_domains` run.
|
||||
"""
|
||||
development = _read("docs/DEVELOPMENT.md")
|
||||
for pointer in ("ouroboros/domains.toml", "docs/DOMAIN_MAP.md",
|
||||
"scripts/check_domains.py --write"):
|
||||
assert pointer in development, f"DEVELOPMENT.md never mentions {pointer}"
|
||||
|
||||
|
||||
def test_recent_abi_retirements_section_carries_the_abi_70_window():
|
||||
"""Section 11.4 carries the compatibility facts an upgrading operator needs.
|
||||
|
||||
|
|
@ -90,40 +71,12 @@ def test_recent_abi_retirements_section_carries_the_abi_70_window():
|
|||
|
||||
|
||||
def test_model_send_design_note_matches_the_observability_contract():
|
||||
"""A reconstruction mismatch is observable without blocking dispatch.
|
||||
|
||||
`ouroboros/model_send_seal.py` implements the rule pinned by
|
||||
`tests/test_model_send_seal.py`: a reconstruction mismatch is a typed
|
||||
durable fact and the call is NOT blocked — dispatch authority stays with
|
||||
the pre-existing in-memory identity re-check. A design note that outranks
|
||||
the code it describes is how the next author reintroduces the gate.
|
||||
"""
|
||||
note = _read("docs/MODEL_SEND_OBSERVABILITY.md")
|
||||
note_flat = " ".join(note.split())
|
||||
|
||||
assert (REPO / "ouroboros" / "model_send_seal.py").exists()
|
||||
assert "refuse dispatch with the existing `PhysicalAttemptPreparationFailed`" \
|
||||
not in note_flat
|
||||
assert "It never calls `verify_sealed_candidate` or reads that record back" in note_flat
|
||||
assert "facts; they do not gate a later model call" in note_flat
|
||||
|
||||
|
||||
def test_settings_docs_name_every_key_owner_and_what_startup_persists():
|
||||
"""Three settings-layer claims the code contradicts (all red pre-fix).
|
||||
|
||||
1. The Default settings table skipped two live `SETTINGS_DEFAULTS` keys
|
||||
(`OUROBOROS_CONTEXT_MODE_AUTO_LOW`, `OUROBOROS_CLAWHUB_REGISTRY_URL`).
|
||||
2. Startup was described as persisting NOTHING, while the server lifespan's
|
||||
`load_settings()` runs `normalize_and_persist_context_mode_compat`,
|
||||
which rewrites the compat pair when it changed under a held lock.
|
||||
3. Invariant 3 and the README pointed at the `config.py` facade as the
|
||||
owner of defaults, after the v7next split moved the vocabularies into
|
||||
sibling leaves.
|
||||
"""
|
||||
"""Keep the live settings and their rows in the tool-read reference table."""
|
||||
arch = _read("docs/ARCHITECTURE.md")
|
||||
arch_flat = " ".join(arch.split())
|
||||
readme_flat = " ".join(_read("README.md").split())
|
||||
development = _read("docs/DEVELOPMENT.md")
|
||||
|
||||
from ouroboros.settings_defaults import SETTINGS_DEFAULTS
|
||||
|
||||
|
|
@ -131,42 +84,9 @@ def test_settings_docs_name_every_key_owner_and_what_startup_persists():
|
|||
assert key in SETTINGS_DEFAULTS
|
||||
assert f"| {key} |" in arch, f"{key} is missing from the settings table"
|
||||
|
||||
# Startup persistence: the compat migration is a real write, not "nothing".
|
||||
# (Other "persists nothing" statements in this document are about the
|
||||
# onboarding failure path and a no-change owner transform, both true.)
|
||||
assert "boot provider normalization in-process and persists nothing" not in arch_flat
|
||||
assert "normalize_and_persist_context_mode_compat" in arch_flat
|
||||
# 4. What retired is the persistent auto-Low MECHANISM, not the key: the
|
||||
# startup sentence called `OUROBOROS_CONTEXT_MODE` itself retired while
|
||||
# the settings table right below it documents the same key as the live
|
||||
# owner-selected horizon.
|
||||
assert "retired `OUROBOROS_CONTEXT_MODE`" not in arch_flat
|
||||
|
||||
# Ownership: the leaves own the vocabularies; config.py stays the facade.
|
||||
# One statement of the book names every owner leaf together.
|
||||
owners = ("settings_defaults", "settings_scales", "model_slots",
|
||||
"review_model_routes", "runtime_limits", "settings_integrity")
|
||||
assert any(all(owner in line for owner in owners) for line in arch.splitlines()), owners
|
||||
assert "exact settings and defaults live in" not in readme_flat
|
||||
assert "settings_defaults.py" in readme_flat
|
||||
assert "an SSOT in `config.py` `SETTINGS_DEFAULTS`" not in development
|
||||
assert "`settings_defaults.py`" in development
|
||||
|
||||
|
||||
def test_architecture_does_not_claim_usage_response_is_the_only_usage_reader():
|
||||
"""`_usage_response` normalizes for accounting; it does not own the block.
|
||||
|
||||
The row claimed "The only reader of a provider's usage block", but every
|
||||
provider adapter reads the raw `usage` dict for its own response envelope
|
||||
(`llm_openai_compatible.py:285`, `llm_anthropic.py`, `llm_local.py`,
|
||||
`local_model.py`). A false absolute in the component map is how the next
|
||||
author "consolidates" a read that was never centralized here; the honest
|
||||
claim is the narrower one the two importers support.
|
||||
"""
|
||||
arch_flat = " ".join(_read("docs/ARCHITECTURE.md").split())
|
||||
|
||||
assert "The only reader of a provider's usage block" not in arch_flat
|
||||
assert "_usage_response.py" in arch_flat
|
||||
"""Shared normalization and adapter response reads remain separate consumers."""
|
||||
for module in ("ouroboros/usage_accounting.py", "ouroboros/loop_llm_call.py"):
|
||||
assert "from ouroboros._usage_response import" in _read(module), module
|
||||
assert 'resp_dict.get("usage")' in _read("ouroboros/llm_openai_compatible.py")
|
||||
|
|
@ -267,218 +187,13 @@ def test_checklist_item_numbers_cited_outside_the_checklist_name_the_current_ite
|
|||
assert not re.search(r"CHECKLISTS items? (?:2|13)\([a-z]\)", dev)
|
||||
|
||||
|
||||
def test_architecture_mentions_shared_log_grouping_and_direct_provider_review_fallback():
|
||||
arch = _read("docs/ARCHITECTURE.md")
|
||||
|
||||
assert "log_events.js" in arch
|
||||
# Direct-provider review fallback: the exclusive-provider resolver and the
|
||||
# model-id migration it relies on are named by their code identifiers.
|
||||
assert "_exclusive_direct_remote_provider_env" in arch
|
||||
assert "migrate_model_value" in arch
|
||||
# The Claude Runtime Status surface is RETIRED with the Claude-SDK
|
||||
# advisory transport (owner-consented, 2026-08-29): the doc must not
|
||||
# resurrect its UI plumbing.
|
||||
assert "refreshClaudeCodeStatus" not in arch
|
||||
assert "claudeRuntimeHasError" not in arch
|
||||
|
||||
|
||||
def test_architecture_limits_finality_and_verdict_claims_to_actual_rows():
|
||||
arch = _read("docs/ARCHITECTURE.md")
|
||||
|
||||
assert "outcome_final" in arch
|
||||
assert "task_summary" in arch
|
||||
assert "Both Main rows, the Project thread rows" not in arch
|
||||
|
||||
|
||||
def test_architecture_maps_cache_split_and_total_budget_authorities():
|
||||
arch = _read("docs/ARCHITECTURE.md")
|
||||
|
||||
assert "_usage_cache_splits.py" in arch
|
||||
settings_row = next(
|
||||
line for line in arch.splitlines() if "settings_setup_contract.py" in line
|
||||
)
|
||||
assert "resolve_total_budget_usd" in settings_row
|
||||
|
||||
|
||||
def test_architecture_documents_skill_schedule_lifecycle_and_evolution_light_block():
|
||||
arch = _read("docs/ARCHITECTURE.md")
|
||||
|
||||
# v6.9 RC2: skill schedule readiness SSOT, lifecycle resync, tombstone
|
||||
# retention, DST contract, and the evolution light-mode hard block.
|
||||
assert "resync_skill_schedules()" in arch
|
||||
assert "skill_readiness_for_execution()" in arch
|
||||
# The automatic experience-review write-back is retired; lessons, the Pattern Register
|
||||
# and the backlog are authored through knowledge_write.
|
||||
assert "MEMORY_ACTIONS_JSON" not in arch and "apply_memory_actions" not in arch
|
||||
assert "Lessons, the Pattern Register and the improvement backlog are authored, never host-generated" in arch
|
||||
|
||||
|
||||
def test_chat_id_addressing_docs_match_the_code_that_routes_it():
|
||||
"""The chat-0 doctrine is documented where implementers read it (P6).
|
||||
|
||||
ARCHITECTURE previously claimed history replay renders chat-0 frames in the
|
||||
Main stream; the Main filter drops them, so a reader following the doc would
|
||||
look for a CLI run's dialogue in a surface that never shows it. That claim
|
||||
must never come back.
|
||||
"""
|
||||
arch = _read("docs/ARCHITECTURE.md")
|
||||
development = _read("docs/DEVELOPMENT.md")
|
||||
|
||||
assert "renders them in the Main stream" not in arch
|
||||
assert "HIDDEN_CHAT_ID" in arch
|
||||
# The headless address is decided at admission (both outcomes have identifiers).
|
||||
assert "log_addressing.ingress_chat_id" in arch
|
||||
assert "WEB_UI_CHAT_ID" in arch
|
||||
# Naming is part of the same admission contract.
|
||||
assert "metadata.title" in arch
|
||||
# A degraded delivery names its own cause: the finish tool's wire form and
|
||||
# the salvage rail's code.
|
||||
assert "finish_task" in arch and "answer_sha256" in arch
|
||||
assert "host_salvage" in arch
|
||||
design = _read("docs/DESIGN.md")
|
||||
assert "Where a card does show a cause, it says it in the owner's" in design
|
||||
assert "the record keeps the machine code" in design
|
||||
# The rule itself lives with the other anti-patterns, not only in a changelog.
|
||||
assert "notification_chat_route" in development and "coerce_chat_identity" in development
|
||||
assert "tests/test_chat_id_truthiness_guard.py" in development
|
||||
|
||||
|
||||
def test_consciousness_prompt_is_the_wake_message_of_an_ordinary_main_turn():
|
||||
"""The wake-up runs on Main's system prompt and tools (owner decision В15); this file
|
||||
is its USER message: no private capability catalog, no round or interval limits."""
|
||||
consciousness = _read("prompts/CONSCIOUSNESS.md")
|
||||
|
||||
assert consciousness.startswith("You are Ouroboros. No one has asked for a task; this turn is yours.")
|
||||
assert "A pause is a legitimate decision" in consciousness
|
||||
assert "Distinguish incremental cash cost from subscription quota" in consciousness
|
||||
assert "ordinary turn, not an assigned deliverable" in consciousness
|
||||
for retired in ("You can:", "up to 10 rounds", "Default wakeup", "background consciousness mode",
|
||||
"Doing nothing is a fine outcome", "`set_next_wakeup`", "`escalate`"):
|
||||
assert retired not in consciousness, retired
|
||||
|
||||
|
||||
def test_phase3_governance_language_is_pinned_without_new_qa_surface():
|
||||
bible = _read("BIBLE.md")
|
||||
development = _read("docs/DEVELOPMENT.md")
|
||||
system = _read("prompts/SYSTEM.md")
|
||||
authoring = _read("docs/CREATING_SKILLS.md")
|
||||
checklists = _read("docs/CHECKLISTS.md")
|
||||
development_flat = " ".join(development.split())
|
||||
|
||||
assert (
|
||||
"Uncertainty calls for judgment, not permission: within its legitimate "
|
||||
"authority, Ouroboros decides autonomously."
|
||||
) in bible
|
||||
assert (
|
||||
"Structural depth is not scope breadth: choose the smallest change that "
|
||||
"eliminates the proven failure class."
|
||||
) in bible
|
||||
|
||||
# The heading DOC_RESIDUE_SKIPPED_SUBSECTIONS selects, and its guard test.
|
||||
# The heading is selected by DOC_RESIDUE_SKIPPED_SUBSECTIONS below.
|
||||
assert "External facts: unknown is not no" in development
|
||||
assert "tests/test_model_name_invariance.py" in development_flat
|
||||
|
||||
for text in (system, authoring, checklists):
|
||||
flat = " ".join(text.split())
|
||||
assert "real consumer flow" in flat
|
||||
assert "screenshot" in flat.lower()
|
||||
assert "vision" in flat.lower()
|
||||
assert "not a universal" in flat or "not universal" in flat or "no universal" in flat
|
||||
|
||||
# Structure of the external-facts table (checked last so a header change
|
||||
# does not mask the assertions above).
|
||||
assert "| Location | Fact | Evidence & re-probe | If stale |" in development
|
||||
|
||||
|
||||
def test_continuity_projection_contract_is_mirrored_across_governance_docs():
|
||||
"""Keep the partial-input rule and its concrete data-flow map from drifting."""
|
||||
bible = " ".join(_read("BIBLE.md").split()).replace("**", "")
|
||||
architecture = " ".join(_read("docs/ARCHITECTURE.md").split())
|
||||
development = " ".join(_read("docs/DEVELOPMENT.md").split())
|
||||
checklists = _read("docs/CHECKLISTS.md")
|
||||
|
||||
assert (
|
||||
"Disclosure is not sufficiency. An omission marker keeps a record honest; "
|
||||
"it does not make the record complete. Where material is omitted, the "
|
||||
"disclosure must name a source this actor can actually resolve. A view known "
|
||||
"to be partial may not authorize PASS, a destructive rewrite, or replacement "
|
||||
"of the full contract it was cut from."
|
||||
) in bible
|
||||
assert "Continuity data-flow map" in architecture
|
||||
assert "state/skill_review_root_tasks.jsonl" in development
|
||||
assert "state/skill_review_root_tasks.jsonl" in architecture
|
||||
assert "SKILL_REVIEW_ROOT_TASKS_WARN_BYTES" in architecture
|
||||
assert "memory/chronicle/records.jsonl" in architecture
|
||||
assert "nine os.stat calls" in _read("ouroboros/agent_startup_checks.py")
|
||||
for item in (
|
||||
"source_completeness",
|
||||
"canonical_memory_fork",
|
||||
"display_identity_replay",
|
||||
):
|
||||
assert item in checklists
|
||||
|
||||
|
||||
def test_architecture_names_all_window_surfaces_and_settlement_order():
|
||||
architecture = _read("docs/ARCHITECTURE.md")
|
||||
assert "reviewer_window.resolve_reviewer_window" in architecture
|
||||
assert "ReviewerWindow.sizing_window" in architecture
|
||||
assert "scope_window" in architecture
|
||||
|
||||
|
||||
def test_phase3_widget_authoring_docs_match_recursive_schema_v1():
|
||||
development = _read("docs/DEVELOPMENT.md")
|
||||
authoring = _read("docs/CREATING_SKILLS.md")
|
||||
architecture = _read("docs/ARCHITECTURE.md")
|
||||
checklists = _read("docs/CHECKLISTS.md")
|
||||
authoring_flat = " ".join(authoring.split())
|
||||
|
||||
for text in (development, authoring, architecture, checklists):
|
||||
for component in ("group", "metric", "callout"):
|
||||
assert component in text
|
||||
assert "maximum depth of 8" in authoring
|
||||
assert "256 nodes" in authoring
|
||||
assert "stable tree path" in authoring
|
||||
assert "transitively passive" in authoring_flat
|
||||
assert "dynamic_ui_schema" in authoring
|
||||
|
||||
|
||||
def test_architecture_mirror_matches_the_split_axes_contracts():
|
||||
"""XG-2.2/XG-2.3 (v6.87.28 review gate): the P6 mirror tracks schedule-vs-dispatch.
|
||||
|
||||
Every pin here failed on the pre-fix docs: the module map claimed
|
||||
`swarm_efficiency.lanes_used` while `_build_swarm_efficiency` emits
|
||||
`lanes_requested`; `subagents.py` was said to own task-group compaction after
|
||||
`compact_task_group` was deleted; the control map said `schedule_subagent`
|
||||
surfaces effective lane(s) after `_finalize_schedule_emission` went
|
||||
request-only; the `swarm_fanout` enumeration promised requested/effective
|
||||
lanes after `_emit_swarm_fanout` dropped `effective_model_lanes`; and both
|
||||
`wait_tasks` field enumerations omitted the emitted `capability_delta`.
|
||||
"""
|
||||
arch = _read("docs/ARCHITECTURE.md")
|
||||
development = _read("docs/DEVELOPMENT.md")
|
||||
arch_flat = " ".join(arch.split())
|
||||
dev_flat = " ".join(development.split())
|
||||
|
||||
# swarm_efficiency reports the REQUEST: lanes_requested, never lanes_used.
|
||||
assert "lanes_requested" in arch
|
||||
# A fanned-out root also reports the depth REQUEST beside those lanes.
|
||||
assert "`requested_depth`" in arch
|
||||
assert "lanes_used" not in arch
|
||||
# Task-group compaction left with the degenerate lane fan-out (v6.87.28).
|
||||
assert "task-group compaction" not in arch
|
||||
assert "compact_task_group" not in arch
|
||||
# schedule_subagent reports the request only; the axes resolve at dispatch.
|
||||
assert "schedule_subagent surfaces effective_lane(s)" not in arch_flat
|
||||
# swarm_fanout carries the requested lane; a wave event written before any
|
||||
# child starts cannot know what the children ran on.
|
||||
assert "requested/effective lanes" not in arch
|
||||
# The canonical projection's field list includes capability_delta; the
|
||||
# handbook points to that owner instead of another drifting enumeration.
|
||||
projection = next(line for line in arch.splitlines() if "_compact_child_projection" in line)
|
||||
assert "capability_delta" in projection
|
||||
assert "control_task_results._wait_for_tasks" in dev_flat
|
||||
|
||||
|
||||
# Identifiers the prompts legitimately name in backticks that are NOT tools:
|
||||
# parameter names, resource roots, write surfaces, typed outcome/status tokens
|
||||
# and runtime-context keys. A NEW snake_case identifier in a prompt must either
|
||||
|
|
@ -495,6 +210,7 @@ PROMPT_NON_TOOL_IDENTIFIERS = frozenset({
|
|||
"write_root", "write_surface",
|
||||
# tool parameters named as cross-tool policy
|
||||
"project_id", "project_name", "recommended_use", "review_rebuttal", "subagent_id", "working_note",
|
||||
"extra_tests", "full_tests", "test_reason",
|
||||
# typed outcomes / statuses / runtime-context keys
|
||||
"needs_manual_target", "started_uncustodied", "owner_client",
|
||||
# safety policy class names (ouroboros/safety.py TOOL_POLICY values) and
|
||||
|
|
@ -769,15 +485,3 @@ def test_architecture_settings_table_mirrors_config_defaults():
|
|||
if key in expected and _normalize_default_cell(cell) != str(config.SETTINGS_DEFAULTS[key]).lower()
|
||||
]
|
||||
assert not mismatched, f"documented default differs from config.SETTINGS_DEFAULTS: {mismatched}"
|
||||
|
||||
|
||||
def test_the_handbook_names_both_layers_of_the_browser_no_undef_gate():
|
||||
"""Two surfaces answer "does every browser identifier resolve?": the
|
||||
dependency-free acorn walker the hermetic commit gate runs, and ESLint's
|
||||
`no-undef` that CI runs as an independent second opinion (D-13). A
|
||||
contributor who sees only one of them either removes the "redundant"
|
||||
other or, seeing a CI-only red, looks for a gate that never ran it.
|
||||
"""
|
||||
development = _read("docs/DEVELOPMENT.md")
|
||||
for pointer in ("web/tests/no_undef.test.js", "web/eslint.config.js", "npm ci"):
|
||||
assert pointer in development, f"DEVELOPMENT.md never mentions {pointer}"
|
||||
|
|
|
|||
|
|
@ -202,14 +202,18 @@ def test_preflight_worker_cap_reaches_every_lane_and_is_recorded(tmp_path, monke
|
|||
stand must set the runtime's own lever to ``max(2, 16 // lanes)`` in the process every lane
|
||||
server inherits, override an ambient value, and record the applied number in the manifest
|
||||
and in each lane row. The runtime reads exactly that key (pinned here, not modified)."""
|
||||
from ouroboros import preflight_runner
|
||||
from ouroboros import preflight_runner, test_parallelism
|
||||
_short_tmp(monkeypatch)
|
||||
assert run_live_lanes.PREFLIGHT_WORKERS_ENV == preflight_runner._PREFLIGHT_WORKERS_ENV
|
||||
assert run_live_lanes.PREFLIGHT_WORKERS_ENV == test_parallelism.WORKERS_ENV
|
||||
assert run_live_lanes.PREFLIGHT_WORKERS_FLOOR == preflight_runner._MIN_PREFLIGHT_WORKERS
|
||||
assert run_live_lanes.parse_args(["--stub", "--lanes", "1"]).preflight_test_workers == 16
|
||||
assert run_live_lanes.parse_args(["--stub", "--lanes", "4"]).preflight_test_workers == 4
|
||||
assert run_live_lanes.parse_args(["--stub", "--lanes", "6"]).preflight_test_workers == 2 # floor at MAX_LANES
|
||||
monkeypatch.setenv(run_live_lanes.PREFLIGHT_WORKERS_ENV, "128") # the operator shell must lose
|
||||
# The stand is the outermost runner here: no inherited test slot or busy host caps its lever.
|
||||
monkeypatch.setenv(test_parallelism.NESTED_WORKERS_ENV, "16")
|
||||
monkeypatch.setattr(test_parallelism, "physical_memory",
|
||||
lambda: test_parallelism.MemorySnapshot(100 * 1024 ** 3, 100 * 1024 ** 3))
|
||||
seen: dict = {}
|
||||
|
||||
def lane(job, args, out, template, stagger, states, seed, budget=None, *, key="", seed_sha=""):
|
||||
|
|
|
|||
|
|
@ -1,12 +1,4 @@
|
|||
"""Structural contracts for the semantic-no-op supervisor event-handler extraction.
|
||||
|
||||
v7next D08/F2.2: the full reference split. The quiet families landed with the
|
||||
D08 lane; the F2.2 cancel/custody lane moved the task-done and evolution-done
|
||||
families into their reference-named leaves, the cancel ingress into
|
||||
events_runtime_controls, and the owner-stop campaign closure beside
|
||||
stop_evolution_tasks in queue_transitions — all emitted from tip bytes.
|
||||
This suite pins the completed shape explicitly.
|
||||
"""
|
||||
"""Supervisor event-family boundaries, compatibility identities, and hot paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -121,18 +113,6 @@ _MOVED_OWNERS = {
|
|||
"_close_campaign_after_owner_stop": queue_transitions,
|
||||
}
|
||||
|
||||
# The dispatcher keeps the table, its loop and the module logger, plus the
|
||||
# unrowed post-cutoff upstream helpers. The cancel/custody organ moved out
|
||||
# with the F2.2 lane; this inventory is pinned so a later edit cannot
|
||||
# silently grow or shrink the facade.
|
||||
_STAYED = ("EVENT_HANDLERS", "dispatch_event", "log")
|
||||
_UNROWED_UPSTREAM = (
|
||||
"_handle_main_llm_call_state",
|
||||
"_parent_delegation_budget",
|
||||
"_routing_attachments",
|
||||
)
|
||||
|
||||
|
||||
def test_event_families_never_import_the_dispatcher_they_serve():
|
||||
"""The dependency is one-way at module scope (the two handle leaves import
|
||||
the parent only inside the call-time handle), which is what keeps the
|
||||
|
|
@ -188,17 +168,6 @@ def test_every_event_family_is_a_hot_code_path_like_its_dispatcher():
|
|||
assert rel in HOT_CODE_PATHS, rel
|
||||
|
||||
|
||||
def test_the_dispatcher_kept_only_the_table_the_loop_and_the_unrowed_helpers():
|
||||
tree = ast.parse(pathlib.Path(events.__file__).read_text(encoding="utf-8"))
|
||||
defined = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
defined.append(node.name)
|
||||
elif isinstance(node, ast.Assign):
|
||||
defined.extend(t.id for t in node.targets if isinstance(t, ast.Name))
|
||||
assert sorted(defined) == sorted(_STAYED + _UNROWED_UPSTREAM)
|
||||
|
||||
|
||||
def test_f22_queue_and_worker_leaves_keep_the_hot_label():
|
||||
"""F2 conformance item 3: the cancel/custody organ moved hot bodies into
|
||||
queue/worker leaves too - the family loop above cannot see those, so pin
|
||||
|
|
|
|||
|
|
@ -324,15 +324,28 @@ def test_task_metrics_are_persisted_and_forwarded_to_live_logs(tmp_path):
|
|||
assert pushed[0]["tool_errors"] == 1
|
||||
|
||||
|
||||
def test_llm_usage_serializer_carries_web_search_sources():
|
||||
"""The llm_usage serializer must persist web_search_sources (GAIA
|
||||
campaign contract). Moved here from tests/test_devtools_benchmarks.py:
|
||||
after the D08 split the serializer lives with its budget family, and
|
||||
this suite owns the llm_usage event surface."""
|
||||
import pathlib
|
||||
src = (pathlib.Path(__file__).resolve().parent.parent
|
||||
/ "supervisor" / "events_budget.py").read_text(encoding="utf-8")
|
||||
assert "web_search_sources" in src
|
||||
def test_llm_usage_serializer_carries_web_search_sources(tmp_path):
|
||||
"""Provider search citations reach both the durable audit and the live log."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from supervisor import events
|
||||
|
||||
sources = [
|
||||
{"url": "https://example.test/one", "title": "First", "content": "First excerpt"},
|
||||
{"url": "https://example.test/two", "title": "Second", "content": "Second excerpt"},
|
||||
]
|
||||
frames = []
|
||||
ctx = SimpleNamespace(DRIVE_ROOT=tmp_path, bridge=SimpleNamespace(push_log=frames.append))
|
||||
|
||||
events._handle_llm_usage(
|
||||
{"type": "llm_usage", "task_id": "search-task", "usage": {"web_search_sources": sources}},
|
||||
ctx,
|
||||
)
|
||||
|
||||
written = json.loads((tmp_path / "logs" / "events.jsonl").read_text(encoding="utf-8"))
|
||||
assert written["web_search_sources"] == sources
|
||||
assert written["task_id"] == "search-task"
|
||||
assert frames == [written]
|
||||
|
||||
|
||||
def test_effort_facts_with_legacy_option_status_survive_logs_without_notices(tmp_path):
|
||||
|
|
|
|||
|
|
@ -297,6 +297,7 @@ def test_only_evolution_push_stays_under_git_lock(
|
|||
"_evolution_publication_stopped_result",
|
||||
lambda *a, **k: order.append("authority") or "",
|
||||
)
|
||||
monkeypatch.setattr(git_tools, "_evolution_test_publication_stop", lambda *a, **k: "") # tested below on its own
|
||||
monkeypatch.setattr(git_tools, "_auto_push", lambda *a, **k: order.append("push") or "")
|
||||
monkeypatch.setattr(
|
||||
git_tools,
|
||||
|
|
@ -382,6 +383,52 @@ def test_revoked_publication_does_not_record_or_anchor_success(tmp_path, monkeyp
|
|||
assert ctx._coupling_review_history == {"keep": True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tests_red", [False, True], ids=["full_green", "red_postcommit"])
|
||||
def test_a_test_refusal_withholds_the_push_but_keeps_reviewed_provenance(tmp_path, monkeypatch, tests_red):
|
||||
"""Unlike lost authority, a red or proof-less post-commit run only withholds publication."""
|
||||
from ouroboros import body_candidate, mutation_attribution, preflight_qualification
|
||||
from ouroboros.tools import git as git_tools
|
||||
|
||||
claim = {"campaign_id": "camp", "transaction_id": "tx", "task_id": "evo"}
|
||||
sha = "d" * 40
|
||||
attempts, baselines, reviewed, pushed = [], [], [], []
|
||||
monkeypatch.setattr(git_tools, "_task_attributed_commit_paths", lambda *a, **k: (None, None, "", ("root", "task")))
|
||||
monkeypatch.setattr(git_tools, "_check_overlapping_review_attempt", lambda *a, **k: "")
|
||||
_patch_commit_seam(monkeypatch, "_record_commit_attempt", lambda *a, **k: attempts.append(k.get("status") or a[2]))
|
||||
monkeypatch.setattr(git_tools, "_acquire_git_lock", lambda *a, **k: pathlib.Path("lock"))
|
||||
monkeypatch.setattr(git_tools, "_release_git_lock", lambda *a, **k: None)
|
||||
monkeypatch.setattr(git_tools, "_prepare_review_commit_worktree", lambda *a, **k: (False, ""))
|
||||
_patch_commit_seam(monkeypatch, "_evolution_commit_authority", lambda *a, **k: (claim, {"ok": True}))
|
||||
_patch_commit_seam(monkeypatch, "_verify_reviewed_commit_binding", lambda *a, **k: (True, ""))
|
||||
_patch_commit_seam(monkeypatch, "_run_reviewed_stage_cycle", lambda *a, **k: {
|
||||
"status": "passed", "pre_fingerprint": {"fingerprint": "pre"},
|
||||
"post_fingerprint": {"fingerprint": "post", "binding": {}}})
|
||||
_patch_commit_seam(monkeypatch, "run_cmd", lambda cmd, cwd=None: sha if cmd[:3] == ["git", "rev-parse", "HEAD"] else "")
|
||||
monkeypatch.setattr(git_tools, "_auto_tag_on_version_bump", lambda *a, **k: "")
|
||||
_patch_commit_seam(monkeypatch, "_record_evolution_commit_receipt", lambda *a, **k: "")
|
||||
|
||||
def post_commit(ctx, message, skip_tests, warning, force=False):
|
||||
if tests_red:
|
||||
warning[0] = "TESTS_FAILED (commit preserved): tests/test_far_away.py::test_x"
|
||||
return warning[0]
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(git_tools, "_post_commit_result", post_commit)
|
||||
monkeypatch.setattr(preflight_qualification, "full_proof_error", lambda *a, **k: "")
|
||||
monkeypatch.setattr(git_tools, "_auto_push_after_review", lambda *a, **k: pushed.append(True) or " [pushed]")
|
||||
monkeypatch.setattr(body_candidate, "record_reviewed_commit", lambda ctx, commit, **k: reviewed.append(commit))
|
||||
monkeypatch.setattr(mutation_attribution, "advance_mutation_baseline", lambda *a, **k: baselines.append(a))
|
||||
ctx = SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, branch_dev="ouroboros",
|
||||
current_task_type="evolution", task_id="evo",
|
||||
task_metadata={"evolution_transaction": claim}, _coupling_review_history={})
|
||||
|
||||
result = git_tools._repo_commit_push(ctx, "test commit")
|
||||
|
||||
assert ctx.last_reviewed_commit_sha == sha and reviewed == [sha]
|
||||
assert ("EVOLUTION_PUBLICATION_STOPPED" in result) is tests_red
|
||||
assert pushed == ([] if tests_red else [True])
|
||||
|
||||
|
||||
def test_postcommit_binding_failure_contains_evolution_commit(tmp_path, monkeypatch):
|
||||
from ouroboros.tools import git as git_tools
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ from ouroboros import (
|
|||
extension_surface_names,
|
||||
)
|
||||
|
||||
REPO = pathlib.Path(__file__).parents[1]
|
||||
|
||||
_LEAVES = (
|
||||
extension_registry_state,
|
||||
extension_surface_names,
|
||||
|
|
@ -175,23 +173,3 @@ def test_the_public_loader_surface_is_unchanged():
|
|||
]
|
||||
for name in extension_loader.__all__:
|
||||
assert hasattr(extension_loader, name), name
|
||||
|
||||
|
||||
def test_extension_extraction_size_bounds_have_meaningful_headroom():
|
||||
from ouroboros.review import BAND_MODULE_MAX_LINES, TARGET_MODULE_LINES
|
||||
from ouroboros.size_ratchet_manifest import BAND_PATHS
|
||||
|
||||
counts = {
|
||||
module.__name__: len(
|
||||
pathlib.Path(module.__file__).read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
for module in (extension_loader, *_LEAVES)
|
||||
}
|
||||
assert counts["ouroboros.extension_loader"] <= TARGET_MODULE_LINES
|
||||
for module, count in counts.items():
|
||||
path = module.replace(".", "/") + ".py"
|
||||
limit = TARGET_MODULE_LINES
|
||||
if path in BAND_PATHS:
|
||||
assert BAND_PATHS[path], f"new extension band entry needs its rationale: {path}"
|
||||
limit = BAND_MODULE_MAX_LINES
|
||||
assert count <= limit, f"{path}: {count} lines exceeds its {limit}-line bound"
|
||||
|
|
|
|||
|
|
@ -132,8 +132,6 @@ def test_external_review_script_is_a_wrapper_over_the_review_operation():
|
|||
import scripts.run_external_review as module
|
||||
|
||||
source = Path("scripts/run_external_review.py").read_text(encoding="utf-8")
|
||||
assert "v6.10.0" not in source
|
||||
assert "Google Colab" not in source
|
||||
# Contributor lane: the review operation over the frozen base..head subject.
|
||||
assert "ouroboros.tools import review_change" in source
|
||||
assert 'root="system_repo", surface="change"' in source
|
||||
|
|
@ -164,22 +162,6 @@ def test_external_review_script_defaults_to_pro_mode():
|
|||
assert 'setdefault("OUROBOROS_RUNTIME_MODE", "pro")' in source
|
||||
|
||||
|
||||
def test_the_operator_lane_sets_no_retired_diff_aware_knob():
|
||||
"""The commit gate pays the suite on every diff (owner answer A, 2026-10-08), so nothing
|
||||
reads ``OUROBOROS_PREFLIGHT_DIFF_AWARE`` any more; the lane neither sets it nor leaves a
|
||||
mention for an operator to copy. The whole tree under test has no reader of the name."""
|
||||
name = "OUROBOROS_PREFLIGHT_" + "DIFF_AWARE"
|
||||
assert name not in Path("scripts/run_external_review.py").read_text(encoding="utf-8")
|
||||
readers = [
|
||||
path.relative_to(REPO_ROOT).as_posix()
|
||||
for folder in ("ouroboros", "supervisor", "scripts", "web", "docs", "prompts")
|
||||
for path in (REPO_ROOT / folder).rglob("*")
|
||||
if path.is_file() and path.suffix in {".py", ".js", ".md", ".json"}
|
||||
and name in path.read_text(encoding="utf-8", errors="ignore")
|
||||
]
|
||||
assert readers == []
|
||||
|
||||
|
||||
def test_external_review_script_resolves_models_and_efforts(monkeypatch):
|
||||
for key in (
|
||||
"OPENAI_API_KEY",
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
"""The public workspace contract must keep ordinary folders visible."""
|
||||
|
||||
from pathlib import Path
|
||||
from tests._governance_docs_shared import architecture_text
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_external_workspace_docs_distinguish_plain_folders_from_git_operations():
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
architecture = architecture_text(ROOT)
|
||||
control = (ROOT / "ouroboros" / "tools" / "control.py").read_text(encoding="utf-8")
|
||||
assert "ordinary folders or separate Git worktree roots" in readme
|
||||
assert "ordinary file and process work runs directly" in readme
|
||||
assert "workspace_admission.validate_workspace_root()" in architecture
|
||||
assert "ordinary folder or Git worktree root" in control
|
||||
assert "File/process work supports ordinary folders; Git operations need a worktree" in control
|
||||
assert "External workspaces must be separate Git worktree roots" not in readme
|
||||
assert "must be a git worktree root outside" not in control
|
||||
|
|
@ -34,8 +34,6 @@ def test_new_file_discard_and_context_menu_clamp_regressions():
|
|||
def test_files_page_explains_manager_role_and_directory_affordance():
|
||||
source = _read("web/modules/files.js")
|
||||
|
||||
assert "This is a file manager, not a chat attachment picker." in source
|
||||
assert "Open a folder or file from the left panel to browse, preview, or edit its contents." in source
|
||||
assert "button.type = 'button';" in source
|
||||
assert "(entry.type === 'file' ? formatFileSize(entry.size) : 'open')" in source
|
||||
|
||||
|
|
|
|||
|
|
@ -85,14 +85,6 @@ class TestTelegramChatIdRemoval:
|
|||
assert "telegram_chat_id" not in hints, cls.__name__
|
||||
assert "transport" in hints, cls.__name__
|
||||
|
||||
def test_no_runtime_producer_left(self):
|
||||
# The history mapper was the ONLY emitter; grep-level absence pin.
|
||||
import inspect
|
||||
|
||||
from ouroboros.gateway import history as gateway_history
|
||||
|
||||
source = inspect.getsource(gateway_history)
|
||||
assert '"telegram_chat_id": ' not in source
|
||||
|
||||
def test_legacy_stored_row_replays_without_reemitting_the_key(self, tmp_path):
|
||||
from ouroboros.gateway.history import _collect_chat_rows
|
||||
|
|
|
|||
|
|
@ -369,16 +369,10 @@ def _commit(repo, message, files=(), delete=()):
|
|||
return _git(repo, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def replace_scene(tmp_path, monkeypatch):
|
||||
"""The real update doors (``post``; ``apply`` is replace): preflight prepare (rescue, keep ref,
|
||||
intent), tx, checkout and the fenced rollback. Only the fetching plan, the worker fence/revival
|
||||
and the restart callback are recorded. The local branch carries one owner commit the target
|
||||
lacks, so a keep ref is made."""
|
||||
from ouroboros.gateway import control
|
||||
from supervisor import update_merge
|
||||
|
||||
repo = _init_repo(tmp_path)
|
||||
@pytest.fixture(scope="session")
|
||||
def replacement_history(tmp_path_factory):
|
||||
"""Build the common four-commit history once; expose immutable bytes, never its root."""
|
||||
repo = _init_repo(tmp_path_factory.mktemp("git-lock-history"))
|
||||
root = _commit(repo, "root", {"BIBLE.md": "constitution\n", "ouroboros/store.py": "# store\n",
|
||||
"ouroboros/usage_store.py": "# usage\n", "seed.txt": "seed\n"})
|
||||
base = _commit(repo, "owner work", {"local.txt": "owner commit\n"})
|
||||
|
|
@ -388,6 +382,33 @@ def replace_scene(tmp_path, monkeypatch):
|
|||
_git(repo, "checkout", "-q", "ouroboros")
|
||||
(repo / ".git" / git_ops.MANAGED_REPO_META_NAME).write_text(
|
||||
json.dumps({"managed_local_branch": "ouroboros"}), encoding="utf-8")
|
||||
paths = sorted(repo.rglob("*"))
|
||||
directories = tuple(path.relative_to(repo) for path in paths if path.is_dir())
|
||||
files = tuple((path.relative_to(repo), path.read_bytes(), path.stat().st_mode & 0o777)
|
||||
for path in paths if path.is_file())
|
||||
return base, target, storeless, directories, files
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def replace_scene(tmp_path, monkeypatch, replacement_history):
|
||||
"""The real update doors (``post``; ``apply`` is replace): preflight prepare (rescue, keep ref,
|
||||
intent), tx, checkout and the fenced rollback. Only the fetching plan, the worker fence/revival
|
||||
and the restart callback are recorded. The local branch carries one owner commit the target
|
||||
lacks, so a keep ref is made."""
|
||||
from ouroboros.gateway import control
|
||||
from supervisor import update_merge
|
||||
|
||||
base, target, storeless, directories, files = replacement_history
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
# Every worktree file AND .git file gets its own inode: no hardlinks,
|
||||
# shared object alternates, mutable config/index, refs or lock files.
|
||||
for name in directories:
|
||||
(repo / name).mkdir(parents=True, exist_ok=True)
|
||||
for name, body, mode in files:
|
||||
path = repo / name
|
||||
path.write_bytes(body)
|
||||
path.chmod(mode)
|
||||
data = tmp_path / "data"
|
||||
(data / "logs").mkdir(parents=True)
|
||||
for name, value in {"REPO_DIR": repo, "DRIVE_ROOT": data, "BRANCH_DEV": "ouroboros"}.items():
|
||||
|
|
|
|||
|
|
@ -37,8 +37,6 @@ from tests._git_review_pipeline_shared import (
|
|||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_ctx(tmp_path):
|
||||
"""Yield ``(git_module, ToolContext)`` — the canonical git pipeline setup."""
|
||||
|
|
@ -204,11 +202,6 @@ class TestAutoPushBehavior:
|
|||
assert hasattr(git_mod, "_auto_push")
|
||||
assert callable(git_mod._auto_push)
|
||||
|
||||
def test_auto_push_is_best_effort(self):
|
||||
git_mod = _get_git_module()
|
||||
source = inspect.getsource(git_mod._auto_push)
|
||||
assert "except Exception" in source
|
||||
assert "non-fatal" in source.lower() or "non_fatal" in source.lower()
|
||||
|
||||
def test_managed_tests_gate_before_tag_and_ordinary_tests_keep_prior_order(self):
|
||||
git_mod = _get_git_module()
|
||||
|
|
@ -261,12 +254,6 @@ class TestRemoteConfigSurfacing:
|
|||
source = (pathlib.Path(REPO) / "ouroboros" / "gateway" / "settings.py").read_text(encoding="utf-8")
|
||||
assert '"warnings"' in source
|
||||
|
||||
def test_remote_credentials_migration_not_wired_at_startup(self):
|
||||
"""Legacy token-in-URL migration is no longer run on startup."""
|
||||
server_path = pathlib.Path(REPO) / "server.py"
|
||||
source = server_path.read_text(encoding="utf-8")
|
||||
assert "migrate_remote_credentials" not in source
|
||||
|
||||
|
||||
# --- credential helper safety (legacy migration retired) ---
|
||||
|
||||
|
|
@ -275,17 +262,10 @@ class TestRemoteCredentialConfiguration:
|
|||
git_ops = _get_git_ops_module()
|
||||
assert not hasattr(git_ops, "migrate_remote_credentials")
|
||||
|
||||
def test_configure_remote_uses_local_credential_helper(self):
|
||||
git_ops = _get_git_ops_module()
|
||||
configure_source = inspect.getsource(git_ops.configure_remote)
|
||||
helper_source = inspect.getsource(git_ops._configure_credential_helper)
|
||||
assert "_configure_credential_helper" in configure_source
|
||||
assert ".git/credentials" in helper_source
|
||||
|
||||
def test_startup_setup_only_configures_current_settings_token(self):
|
||||
server_runtime = importlib.import_module("ouroboros.server_runtime")
|
||||
source = inspect.getsource(server_runtime.setup_remote_if_configured)
|
||||
assert "migrate_remote_credentials" not in source
|
||||
assert "configure_personal_remote" in source
|
||||
|
||||
|
||||
|
|
@ -324,16 +304,6 @@ class TestSandboxCoversRepoWrite:
|
|||
) == ["BIBLE.md"]
|
||||
|
||||
|
||||
# --- index-full instruction fix ---
|
||||
|
||||
class TestIndexFullInstruction:
|
||||
def test_system_md_warns_against_index_full(self):
|
||||
system_md = pathlib.Path(REPO) / "prompts" / "SYSTEM.md"
|
||||
content = system_md.read_text(encoding="utf-8")
|
||||
assert "Do NOT call" in content or "reserved internal name" in content
|
||||
assert "knowledge_list" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check 7: P9 history limits in _preflight_check (v4.41.0)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ def _stub_preflight_lanes(repo, monkeypatch):
|
|||
subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True)
|
||||
monkeypatch.setenv("OUROBOROS_PRE_PUSH_TESTS", "1")
|
||||
monkeypatch.setenv("OUROBOROS_PREFLIGHT_TEST_WORKERS", "2")
|
||||
monkeypatch.setenv("OURO_TEST_WORKER_BUDGET", "2") # nested slots: the gate must really run two workers
|
||||
monkeypatch.delenv("OUROBOROS_PREFLIGHT_SERIAL", raising=False)
|
||||
monkeypatch.delenv("OUROBOROS_PREFLIGHT_TIMEOUT_SEC", raising=False)
|
||||
monkeypatch.setattr(pr, "_verify_preflight_plugins", lambda *a: [])
|
||||
|
|
@ -655,20 +656,6 @@ def _look(monkeypatch, calls):
|
|||
monkeypatch.setattr(commit_gate, "run_commit_preflight", look)
|
||||
|
||||
|
||||
def test_the_system_prompt_says_which_look_the_commit_record_counts():
|
||||
"""The model's own prompt states the record rule the tests below pin: only the row
|
||||
named in the ``commit_reviewed`` call itself is the commit's preflight; a separate
|
||||
``preflight_review`` is an early look of its own; no name records not performed."""
|
||||
import pathlib
|
||||
|
||||
text = (pathlib.Path(__file__).resolve().parents[1] / "prompts" / "SYSTEM.md").read_text(encoding="utf-8")
|
||||
phrase = " ".join(text.split())
|
||||
assert ("the commit's record counts an early look only when that call itself names the row "
|
||||
"(`commit_reviewed(preflight_reviewer=…)`)") in phrase
|
||||
assert "a separate `preflight_review` is an early look of its own" in phrase
|
||||
assert "a commit that names no row records its preflight as not performed" in phrase
|
||||
|
||||
|
||||
@pytest.mark.parametrize("skip, status", [(False, "not_performed"), (True, "skipped")])
|
||||
def test_without_a_named_row_the_record_states_the_fact_and_nothing_looks(candidate, monkeypatch, skip, status):
|
||||
calls = []
|
||||
|
|
@ -706,35 +693,10 @@ def test_a_doc_only_diff_pays_the_suite_like_any_other_diff(candidate, monkeypat
|
|||
|
||||
|
||||
def test_every_site_states_the_one_tests_rule_for_a_documentation_diff():
|
||||
"""Owner answer A (2026-10-08): the suite runs before any commit to the body, a
|
||||
documentation-only diff included; ``skip_tests`` is the one exemption. The gate's
|
||||
condition, its comments, the checklist line and the test modules that once pinned the
|
||||
retired carve say the same thing, so no site describes a doc-only diff as exempt."""
|
||||
"""The gate condition preserves the test exemption and managed proof requirement."""
|
||||
import inspect
|
||||
import pathlib
|
||||
|
||||
import tests.test_git_review_tests_gate as tests_gate
|
||||
import tests.test_skip_tests_doc_only as classifier_tests
|
||||
|
||||
repo = pathlib.Path(git.__file__).resolve().parents[2]
|
||||
checklist = (repo / "docs" / "CHECKLISTS.md").read_text(encoding="utf-8")
|
||||
sites = {
|
||||
"git._preflight_and_tests_gate": inspect.getsource(git._preflight_and_tests_gate),
|
||||
"git._managed_candidate_needs_proof": inspect.getsource(git._managed_candidate_needs_proof),
|
||||
"docs/CHECKLISTS.md": checklist,
|
||||
"tests/test_git_review_tests_gate.py": tests_gate.__doc__ or "",
|
||||
"tests/test_skip_tests_doc_only.py": classifier_tests.__doc__ or "",
|
||||
"this module": TestReleaseMetadataPreflight.test_doc_only_carve_is_the_commit_gate_classifier.__doc__ or "",
|
||||
}
|
||||
retired = ("exempt from the suite", "preflight bypass", "skip_tests/doc-only",
|
||||
"the tests rule applies", "diff-aware")
|
||||
for name, text in sites.items():
|
||||
for phrase in retired:
|
||||
assert phrase not in text, (name, phrase)
|
||||
assert "if not message and (not skip_tests or _managed_needs_proof):" in sites["git._preflight_and_tests_gate"]
|
||||
assert "documentation has tests too" in sites["git._preflight_and_tests_gate"]
|
||||
assert "a documentation-only diff included" in checklist
|
||||
assert "a documentation-only diff included" in sites["tests/test_git_review_tests_gate.py"]
|
||||
assert "if not message and (not skip_tests or _managed_needs_proof):" in inspect.getsource(git._preflight_and_tests_gate)
|
||||
|
||||
|
||||
def test_a_syntax_error_blocks_before_the_tests_and_the_look(candidate, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -17,14 +17,6 @@ def _heartbeat_ctx(tmp_path, task):
|
|||
), pushed
|
||||
|
||||
|
||||
def test_routine_heartbeat_is_not_rendered_as_chat_message() -> None:
|
||||
from supervisor import queue
|
||||
|
||||
source = inspect.getsource(queue._enforce_task_timeouts_locked)
|
||||
assert "running for" not in source
|
||||
assert "heartbeat_lag=" not in source
|
||||
|
||||
|
||||
def test_liveness_and_incident_controls_remain_active() -> None:
|
||||
from supervisor import queue
|
||||
|
||||
|
|
@ -302,7 +294,5 @@ def test_routine_cancel_outcomes_publish_nothing_to_the_owner_chat(monkeypatch)
|
|||
|
||||
_handle_cancel_task({"task_id": ""}, _Ctx2())
|
||||
assert sent == []
|
||||
# The handler no longer carries the prose that leaked, nor a send at all: source pin.
|
||||
src = inspect.getsource(_handle_cancel_task)
|
||||
for leaked in ("teardown confirmed", "had already finished", "no such live task", "send_with_budget("):
|
||||
assert leaked not in src
|
||||
# Custody owns presentation; this event handler must not send its own row.
|
||||
assert "send_with_budget(" not in inspect.getsource(_handle_cancel_task)
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ Three structural guarantees, pinned independently of any single writer:
|
|||
(``write_bytes_atomic``, ``write_text_atomic``, ``atomic_write_json``,
|
||||
``supervisor.state.atomic_write_text``) replaces through it, so guarding it
|
||||
guards them all rather than one writer at a time (RES-14b).
|
||||
3. Batch recipe: a real run of the update-merge suites under a throwaway HOME
|
||||
plus full OUROBOROS_* isolation leaves the live-shaped data root untouched
|
||||
3. Batch recipe: a real child pytest running supervisor writers under a throwaway
|
||||
HOME plus full OUROBOROS_* isolation leaves the live-shaped data root untouched
|
||||
(pre/post inventory delta must be empty) — the AGENTS hermeticity etalon
|
||||
(``find <live-root> -newermt <start>`` empty) as a regression test.
|
||||
"""
|
||||
|
|
@ -30,6 +30,93 @@ from ouroboros.utils import append_jsonl, atomic_write_json, write_bytes_atomic,
|
|||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _run_hermetic_child_pytest(args, env):
|
||||
from ouroboros.platform_layer import kill_process_tree
|
||||
from ouroboros.process_containment import ProcessContainer
|
||||
|
||||
container = ProcessContainer()
|
||||
proc = None
|
||||
try:
|
||||
proc = container.spawn(
|
||||
[sys.executable, "-m", "pytest", "-q", "-x", "-p", "no:cacheprovider", *args],
|
||||
cwd=str(REPO_ROOT), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
)
|
||||
# Bound a stuck child; this is a custody budget, never a speed assertion.
|
||||
stdout, stderr = proc.communicate(timeout=900)
|
||||
return subprocess.CompletedProcess(proc.args, proc.returncode, stdout, stderr)
|
||||
finally:
|
||||
try:
|
||||
if proc is not None and proc.poll() is None:
|
||||
kill_process_tree(proc)
|
||||
finally:
|
||||
try:
|
||||
error = container.reap() # Includes descendants after an already-exited root.
|
||||
if proc is not None:
|
||||
# Bound kernel reaping after the kill, not application behavior.
|
||||
proc.wait(timeout=10)
|
||||
assert not error, error
|
||||
finally:
|
||||
container.close()
|
||||
if proc is not None:
|
||||
for stream in (proc.stdout, proc.stderr):
|
||||
if stream is not None:
|
||||
stream.close()
|
||||
|
||||
|
||||
_HERMETIC_WRITER_SUITE = '''
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_supervisor_roots_and_real_writers_are_isolated(monkeypatch):
|
||||
from ouroboros import config
|
||||
from ouroboros.utils import append_jsonl, atomic_write_json, write_bytes_atomic, write_text_atomic
|
||||
from supervisor import git_ops, queue, state, update_merge, workers
|
||||
|
||||
root = pathlib.Path(os.environ["OUROBOROS_DATA_DIR"]).resolve()
|
||||
live = pathlib.Path(LIVE_SHAPED).resolve()
|
||||
assert root != live and live not in root.parents
|
||||
assert config.DATA_DIR.resolve() == root
|
||||
assert config.SETTINGS_PATH.resolve() == root / "settings.json"
|
||||
for owner in (git_ops, queue, state, workers):
|
||||
assert owner.DRIVE_ROOT.resolve() == root, owner.__name__
|
||||
assert state.STATE_PATH == root / "state" / "state.json"
|
||||
assert queue.QUEUE_SNAPSHOT_PATH == root / "state" / "queue_snapshot.json"
|
||||
assert git_ops.REPO_DIR == workers.REPO_DIR == root / "repo"
|
||||
|
||||
# The update-merge logging path that originally leaked, with no fake writer.
|
||||
assert update_merge._log_supervisor({"type": "hermetic_writer_probe", "probe": True})
|
||||
rows = [json.loads(line) for line in
|
||||
(root / "logs" / "supervisor.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
assert [row["probe"] for row in rows if row.get("type") == "hermetic_writer_probe"] == [True]
|
||||
direct = root / "logs" / "direct.jsonl"
|
||||
assert append_jsonl(direct, {"probe": True})
|
||||
assert json.loads(direct.read_text(encoding="utf-8")) == {"probe": True}
|
||||
|
||||
writers = (
|
||||
("bytes", lambda p: write_bytes_atomic(p, b'{"probe": true}')),
|
||||
("text", lambda p: write_text_atomic(p, '{"probe": true}')),
|
||||
("json", lambda p: atomic_write_json(p, {"probe": True})),
|
||||
("state", lambda p: state.atomic_write_text(p, '{"probe": true}')),
|
||||
)
|
||||
for label, write in writers:
|
||||
target = root / "writer-probe" / (label + ".json")
|
||||
write(target)
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {"probe": True}, label
|
||||
|
||||
# Both shared writer seams must also refuse the parent's synthetic live root.
|
||||
monkeypatch.setenv("OUROBOROS_TEST_LIVE_DATA_ROOT", str(live))
|
||||
monkeypatch.delenv("OUROBOROS_ALLOW_LIVE_DATA_TESTS", raising=False)
|
||||
with pytest.raises(RuntimeError, match="PYTEST_LIVE_DATA_WRITE_BLOCKED"):
|
||||
append_jsonl(live / "logs" / "probe.jsonl", {"probe": True})
|
||||
with pytest.raises(RuntimeError, match="PYTEST_LIVE_DATA_WRITE_BLOCKED"):
|
||||
atomic_write_json(live / "state" / "probe.json", {"probe": True})
|
||||
'''
|
||||
|
||||
|
||||
def test_append_jsonl_fails_closed_on_live_root_write(monkeypatch, tmp_path):
|
||||
fake_live = tmp_path / "fake-live"
|
||||
monkeypatch.setenv("OUROBOROS_PYTEST_ACTIVE", "1")
|
||||
|
|
@ -72,20 +159,18 @@ def test_every_atomic_writer_fails_closed_on_live_root_write(monkeypatch, tmp_pa
|
|||
|
||||
@pytest.mark.serial
|
||||
def test_update_merge_suites_leave_live_shaped_root_untouched(tmp_path):
|
||||
"""The E2E class pin: the exact suites that leaked in the live repro run
|
||||
under a throwaway HOME + full env isolation, and the live-shaped root
|
||||
(``$HOME/Ouroboros/data``) shows an EMPTY pre/post inventory delta."""
|
||||
"""Keep the historical node's child-startup/writer isolation witness.
|
||||
|
||||
Update/stash scenarios run in their own modules; replaying them here only
|
||||
repeats their git fixtures. This child reaches their actual logging writer
|
||||
and both durable-write seams after the real conftest isolation boundary.
|
||||
"""
|
||||
fake_home = tmp_path / "home"
|
||||
iso = tmp_path / "iso"
|
||||
fake_home.mkdir()
|
||||
# The suites commit in throwaway repos with repo-local identity, but a
|
||||
# hermetic HOME must still offer a global one for any stray `git commit`.
|
||||
(fake_home / ".gitconfig").write_text(
|
||||
"[user]\n\tname = hermetic-pin\n\temail = hermetic-pin@example.invalid\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
env = {**os.environ,
|
||||
"HOME": str(fake_home),
|
||||
"USERPROFILE": str(fake_home),
|
||||
"OUROBOROS_APP_ROOT": str(iso),
|
||||
"OUROBOROS_REPO_DIR": str(iso / "repo"),
|
||||
"OUROBOROS_DATA_DIR": str(iso / "data"),
|
||||
|
|
@ -98,14 +183,18 @@ def test_update_merge_suites_leave_live_shaped_root_untouched(tmp_path):
|
|||
pre_inventory = sorted(str(p) for p in live_shaped.rglob("*")) if live_shaped.exists() else []
|
||||
assert pre_inventory == []
|
||||
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "-q", "-x", "-p", "no:cacheprovider",
|
||||
"tests/test_update_dirty_stash.py", "tests/test_update_merge_plan.py"],
|
||||
cwd=str(REPO_ROOT), env=env, capture_output=True, text=True, timeout=900,
|
||||
suite = tmp_path / "child-suite"
|
||||
suite.mkdir()
|
||||
child = suite / "test_root_writers.py"
|
||||
child.write_text(f"LIVE_SHAPED = {str(live_shaped)!r}\n" + _HERMETIC_WRITER_SUITE,
|
||||
encoding="utf-8")
|
||||
proc = _run_hermetic_child_pytest(
|
||||
["-p", "tests.conftest", "-o", "addopts=", "-n", "0",
|
||||
"--rootdir", str(suite), "--confcutdir", str(suite), str(child)], env,
|
||||
)
|
||||
assert proc.returncode == 0, (proc.stdout or "")[-4000:] + (proc.stderr or "")[-2000:]
|
||||
|
||||
post_inventory = sorted(str(p) for p in live_shaped.rglob("*")) if live_shaped.exists() else []
|
||||
assert post_inventory == [], (
|
||||
f"update-merge suites leaked into the live-shaped data root: {post_inventory}"
|
||||
f"supervisor writers leaked into the live-shaped data root: {post_inventory}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,34 +1,4 @@
|
|||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_prompts_do_not_infer_current_human_from_authors():
|
||||
system = (REPO_ROOT / "prompts" / "SYSTEM.md").read_text(encoding="utf-8")
|
||||
consciousness = (REPO_ROOT / "prompts" / "CONSCIOUSNESS.md").read_text(encoding="utf-8")
|
||||
memory = (REPO_ROOT / "ouroboros" / "memory.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "my human" in system
|
||||
assert "I do not know their name" in system
|
||||
assert "README, BIBLE, git history, or author" in system
|
||||
assert "your human" in consciousness and "the user" not in consciousness
|
||||
assert "I do not yet know my human's name or profile" in memory
|
||||
assert "Anton" not in system
|
||||
assert "Razzhigaev" not in system
|
||||
|
||||
|
||||
def test_live_task_message_marker_uses_my_human_wording(tmp_path):
|
||||
system = (REPO_ROOT / "prompts" / "SYSTEM.md").read_text(encoding="utf-8")
|
||||
# v7 L-B split: sweep the whole loop family (facade + leaves).
|
||||
loop_dir = REPO_ROOT / "ouroboros"
|
||||
loop = "".join(
|
||||
path.read_text(encoding="utf-8")
|
||||
for path in [loop_dir / "loop.py", *sorted(loop_dir.glob("loop_*.py"))]
|
||||
)
|
||||
tools = (REPO_ROOT / "ouroboros" / "tools" / "core.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "[Message from my human]" in system
|
||||
# The drained mailbox text (plus its optional surface note) must still go
|
||||
# through the owner-marking wrapper before injection.
|
||||
import queue
|
||||
|
|
@ -67,47 +37,3 @@ def test_live_task_message_marker_uses_my_human_wording(tmp_path):
|
|||
"[Message from independent task t-source]")
|
||||
assert rendered["ancestor_task"].startswith("[Message from ancestor task t-source]")
|
||||
assert rendered[""] == rendered["ancestor_task"], "unknown provenance keeps the tree fallback"
|
||||
assert "[Message from my human]" not in tools
|
||||
assert "[Owner message during task]" not in system
|
||||
assert "[Owner message during task]" not in loop
|
||||
|
||||
|
||||
def test_system_prompt_carries_outcome_honesty_and_capability_acquisition():
|
||||
"""v6.29.0 doctrine pins: the outcome-honesty doctrine and the capability-
|
||||
acquisition boldness clause must stay in SYSTEM.md. The doctrine now says
|
||||
the three endings in WORDS: the ledger identifiers belong to the reviewers'
|
||||
JSON contract, and a prompt that spells them is a prompt the model parrots
|
||||
back to its human. v6.60.0: the FINAL ANSWER marker doctrine deliberately
|
||||
MOVED to the per-task contract (answer_protocol) — the default prompt must
|
||||
NOT carry it (ordinary tasks never see the marker)."""
|
||||
import pathlib
|
||||
|
||||
text = (pathlib.Path(__file__).parent.parent / "prompts" / "SYSTEM.md").read_text(encoding="utf-8")
|
||||
assert "### Outcome honesty" in text
|
||||
# Whitespace-normalized: the doctrine sentence is line-wrapped in the file.
|
||||
normalized = " ".join(text.split())
|
||||
assert "I do not abandon an owed answer" in normalized
|
||||
assert "Presence observation may deliberately end silently without leaving accepted work unfinished" in normalized
|
||||
assert "blocked_with_evidence" not in text
|
||||
assert "best_effort" not in text
|
||||
# Whitespace-normalized: a line-wrapped "FINAL\nANSWER" must not slip past.
|
||||
assert "FINAL ANSWER" not in " ".join(text.split())
|
||||
assert "## Capability Acquisition" in text
|
||||
assert "NOT a \"broad fallback or shim\"" in text
|
||||
# #1323: the owner-approved generic opening, byte for byte, replaces the narrow
|
||||
# "acquisition step, not a blocker" opening; the concrete dependency means stay.
|
||||
section = text.split("## Capability Acquisition", 1)[1].split("\n## ", 1)[0]
|
||||
assert section.strip().startswith(
|
||||
"Before declaring a task blocked, establish what capability or resource is actually missing, "
|
||||
"using evidence available within the task\u2019s scope. Distinguish unavailability from lack of "
|
||||
"authority to use it. When an authorized means is available, use it; otherwise name the specific "
|
||||
"blocker and the next action. Availability alone grants no permission.")
|
||||
assert "not a blocker" not in section
|
||||
assert "(`pip`/`uv`/`pip3`/`brew`/`apt`)" in section and "credential" not in section.lower()
|
||||
|
||||
|
||||
def test_public_publishing_still_requires_creator_permission():
|
||||
system = (REPO_ROOT / "prompts" / "SYSTEM.md").read_text(encoding="utf-8")
|
||||
assert "Do not publish or make content publicly accessible" in system
|
||||
assert "permission from the creator" in system
|
||||
assert "My human may grant that permission only if they" in system
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"""A timed-out image call stays findable: real blocked reads, the real executor, recover_image().
|
||||
"""A timed-out image call stays findable through the executor and recover_image().
|
||||
|
||||
The executor that abandons a timed-out tool reports a bare TOOL_TIMEOUT; its worker keeps the
|
||||
paid attempt and may still adopt the response. A wire mock cannot block a socket read, so each
|
||||
peer is a real 127.0.0.1 HTTP server whose chosen reply waits for the test. The deadline clamp
|
||||
is the real one.
|
||||
Recovery-state permutations pause an in-process HTTP exchange and inject only the caller's
|
||||
expired wait. The real worker, gateway, ledger and late adoption still run. Four socket cases
|
||||
keep the physical deadline proof: an engine result, late headers, a trickling body and a URL
|
||||
image download. Every call checks the real near-deadline clamp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -65,6 +65,33 @@ class _Peer(ThreadingHTTPServer):
|
|||
self.loop.join()
|
||||
|
||||
|
||||
class _Exchange:
|
||||
"""Pause a transport exchange without waiting out another physical deadline."""
|
||||
|
||||
url, loop = "http://127.0.0.1:1", None # intercepted before any socket can be opened
|
||||
|
||||
def __init__(self, app, hold=lambda _request: False):
|
||||
self.app, self.hold, self.requests = app, hold, []
|
||||
self.release, self.entered = threading.Event(), threading.Event()
|
||||
|
||||
def send(self, request):
|
||||
assert request.url.host == "127.0.0.1", request.url
|
||||
self.requests.append((request.method, request.url.path))
|
||||
if self.hold(request):
|
||||
self.entered.set()
|
||||
assert self.release.wait(60), "test did not release its paused image exchange"
|
||||
return self.app(request)
|
||||
|
||||
def expired_wait(self, future, _timeout):
|
||||
# This bounds a broken test's rendezvous, not a product timeout or outcome.
|
||||
assert self.entered.wait(10), "the image worker did not reach the selected exchange"
|
||||
assert not future.done(), "the exchange must still be held when the caller leaves"
|
||||
raise TimeoutError("injected expiry after the image exchange entered")
|
||||
|
||||
def close(self):
|
||||
self.release.set()
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
|
|
@ -113,7 +140,11 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
@pytest.fixture
|
||||
def peers():
|
||||
opened, before = [], set(threading.enumerate())
|
||||
yield lambda *args: opened.append(_Peer(*args)) or opened[-1]
|
||||
def open_peer(*args, real_socket=True):
|
||||
opened.append((_Peer if real_socket else _Exchange)(*args))
|
||||
return opened[-1]
|
||||
|
||||
yield open_peer
|
||||
for peer in opened:
|
||||
peer.release.set()
|
||||
for thread in set(threading.enumerate()) - before - {peer.loop for peer in opened}:
|
||||
|
|
@ -133,8 +164,8 @@ def _nth(method, path, nth=1):
|
|||
return hold
|
||||
|
||||
|
||||
def _engine(monkeypatch, peers, wire, hold=lambda _request: False):
|
||||
peer = peers(wire, hold)
|
||||
def _engine(monkeypatch, peers, wire, hold=lambda _request: False, *, real_socket=True):
|
||||
peer = peers(wire, hold, real_socket=real_socket)
|
||||
port = int(peer.url.rsplit(":", 1)[1])
|
||||
gateway = lambda: cx.ClaudexorGateway(cx.DaemonEndpoint("127.0.0.1", port, "test-control-token")) # noqa: E731
|
||||
monkeypatch.setattr("ouroboros.claudexor_daemon.read_owned_gateway", gateway)
|
||||
|
|
@ -143,8 +174,9 @@ def _engine(monkeypatch, peers, wire, hold=lambda _request: False):
|
|||
return peer
|
||||
|
||||
|
||||
def _direct(monkeypatch, peers, app, hold=lambda _request: False, shape=lambda _request: None):
|
||||
peer = peers(app, hold, shape)
|
||||
def _direct(monkeypatch, peers, app, hold=lambda _request: False, shape=lambda _request: None,
|
||||
*, real_socket=True):
|
||||
peer = peers(app, hold, shape) if real_socket else peers(app, hold, real_socket=False)
|
||||
monkeypatch.setattr(image_api, "resolve_image_target", lambda _model: {
|
||||
"provider": "openai", "model": "gpt-image-2", "base_url": peer.url + "/v1",
|
||||
"api_key": "fixture-secret", "headers": {}})
|
||||
|
|
@ -154,12 +186,17 @@ def _direct(monkeypatch, peers, app, hold=lambda _request: False, shape=lambda _
|
|||
return peer
|
||||
|
||||
|
||||
def _run(ctx, monkeypatch, peer, arguments, *, name="generate_image", during=None, keep_blocked=False):
|
||||
def _run(ctx, monkeypatch, peer, arguments, *, name="generate_image", during=None,
|
||||
keep_blocked=False, expire_after_entry=False):
|
||||
"""One call through the real clamp, executor, registry and wait owner. ``during`` runs
|
||||
after the executor answered and before the blocked peer is released."""
|
||||
if peer.release.is_set():
|
||||
peer.release = threading.Event() # a spent release must not pre-release this call's hold
|
||||
monkeypatch.setattr(httpx.HTTPTransport, "handle_request", _loopback_only)
|
||||
if isinstance(peer, _Exchange):
|
||||
peer.entered.clear()
|
||||
monkeypatch.setattr(httpx.HTTPTransport, "handle_request", lambda _transport, request: peer.send(request))
|
||||
else:
|
||||
monkeypatch.setattr(httpx.HTTPTransport, "handle_request", _loopback_only)
|
||||
registry, task = _setup(ctx, monkeypatch)
|
||||
# Stay inside the integer clamp bucket even when both reads share a Windows clock tick.
|
||||
deadline = datetime.now(timezone.utc) + timedelta(seconds=effective_finalization_reserve_sec(ctx) + _CLAMP_SEC + 0.5)
|
||||
|
|
@ -170,7 +207,11 @@ def _run(ctx, monkeypatch, peer, arguments, *, name="generate_image", during=Non
|
|||
before, started = set(threading.enumerate()), time.monotonic()
|
||||
with model_wait.task_model_wait_scope(task=task, drive_root=ctx.drive_root,
|
||||
event_queue=None, worker_slot_held=False) as owner:
|
||||
result = execution._execute_with_timeout(registry, call, ctx.drive_root / "logs", timeout, ctx.task_id)
|
||||
with monkeypatch.context() as wait:
|
||||
if expire_after_entry:
|
||||
assert isinstance(peer, _Exchange)
|
||||
wait.setattr(execution, "future_result", peer.expired_wait)
|
||||
result = execution._execute_with_timeout(registry, call, ctx.drive_root / "logs", timeout, ctx.task_id)
|
||||
elapsed = time.monotonic() - started
|
||||
assert owner.control_reason() is None, "the tool's own bound must not end the task"
|
||||
seen = during(registry) if during else None
|
||||
|
|
@ -220,7 +261,9 @@ def test_timed_out_engine_attempt_is_listed_then_recovered_by_its_original_id(ct
|
|||
hold = {"create-lost": _nth("POST", "/v2/model-operations"), "poll": _nth("GET", "/v2/model-operations/op-one"),
|
||||
"result": _nth("GET", _RESULT), "ack": _nth("POST", "/v2/model-operations/op-one/ack"),
|
||||
"recovery": _nth("GET", _RESULT, 2)}.get(stage, lambda _request: False)
|
||||
peer = _engine(monkeypatch, peers, wire, hold)
|
||||
# The result case retains the real blocked socket / 15-second executor boundary.
|
||||
# Other stages preserve all recovery assertions without repeating that wall wait.
|
||||
peer = _engine(monkeypatch, peers, wire, hold, real_socket=stage == "result")
|
||||
acks = lambda: [call for call in wire.calls if call.url.path.endswith("/ack")] # noqa: E731
|
||||
arguments, name = {"prompt": "fixture", "model": _MODEL}, "generate_image"
|
||||
if stage == "recovery":
|
||||
|
|
@ -230,7 +273,8 @@ def test_timed_out_engine_attempt_is_listed_then_recovered_by_its_original_id(ct
|
|||
arguments, name = {"request_id": _summary(first["result"])["request_id"]}, "recover_image"
|
||||
found = {}
|
||||
registry, result, elapsed, _ = _run(ctx, monkeypatch, peer, arguments, name=name,
|
||||
during=lambda registry: found.update(_discover(registry, ctx, peer)))
|
||||
during=lambda registry: found.update(_discover(registry, ctx, peer)),
|
||||
expire_after_entry=stage not in {"none", "result"})
|
||||
[listed] = found["attempts"]
|
||||
request_id = listed["request_id"]
|
||||
assert found["has_more"] is False and (listed["model"], listed["provider"]) == (_MODEL, "claudexor")
|
||||
|
|
@ -305,18 +349,20 @@ def test_timed_out_direct_attempt_is_listed_then_recovered_by_its_original_id(ct
|
|||
def test_concurrent_same_task_attempts_are_listed_separately_and_never_across_tasks(ctx, monkeypatch, peers):
|
||||
def app(_request):
|
||||
return httpx.Response(200, json={"data": [{"b64_json": base64.b64encode(PNG).decode()}], "padding": "." * 600})
|
||||
peer = _direct(monkeypatch, peers, app, shape=lambda request: "trickle" if request.method == "POST" else None)
|
||||
peer = _direct(monkeypatch, peers, app, hold=lambda request: request.method == "POST", real_socket=False)
|
||||
other = ToolContext(repo_dir=ctx.repo_dir, drive_root=ctx.drive_root, task_id="other-image-task",
|
||||
workspace_root=ctx.workspace_root, workspace_mode="external")
|
||||
usage_accounting.reserve_attempt(usage_accounting.AttemptRequest(
|
||||
model="openai::gpt-image-2", provider="openai", force_unknown_reservation=True, drive_root=ctx.drive_root,
|
||||
task_id=other.task_id, category="image_generation", source="imagegen"))
|
||||
arguments = {"prompt": "fixture", "model": "openai::gpt-image-2"}
|
||||
_registry, first, elapsed, _ = _run(ctx, monkeypatch, peer, arguments, keep_blocked=True)
|
||||
_registry, first, elapsed, _ = _run(ctx, monkeypatch, peer, arguments, keep_blocked=True,
|
||||
expire_after_entry=True)
|
||||
_honest_timeout(first, elapsed)
|
||||
found = {}
|
||||
_registry, second, elapsed, _ = _run(ctx, monkeypatch, peer, arguments,
|
||||
during=lambda registry: found.update(_discover(registry, ctx, peer)))
|
||||
during=lambda registry: found.update(_discover(registry, ctx, peer)),
|
||||
expire_after_entry=True)
|
||||
_honest_timeout(second, elapsed)
|
||||
ids = [row["request_id"] for row in found["attempts"]]
|
||||
# Both in-flight attempts, newest first; neither is chosen, the other task's is absent.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ surface that used to carry the ghost.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
|
@ -83,29 +82,6 @@ def test_neither_init_still_asks_a_caller_for_a_value_it_discards():
|
|||
assert "hard_timeout" not in inspect.signature(workers.init).parameters
|
||||
|
||||
|
||||
def test_the_deprecation_event_path_is_gone_with_its_keys():
|
||||
"""A retired key has nothing left to be loud about; keeping the emitter
|
||||
would leave a dead branch that only a future non-default value could
|
||||
reach — and there can be no future value."""
|
||||
from supervisor import queue
|
||||
|
||||
source = inspect.getsource(queue)
|
||||
assert "deprecated_settings_ignored" not in source
|
||||
assert not hasattr(queue, "_emit_timeout_deprecation_once")
|
||||
assert not hasattr(queue, "_timeout_deprecation_emitted")
|
||||
|
||||
|
||||
def test_status_no_longer_renders_a_legacy_timeout_line():
|
||||
"""``/status`` printed ``legacy_timeouts_ignored: soft=600s, hard=1800s``
|
||||
on every request — an owner-visible report of two constants."""
|
||||
from supervisor import state
|
||||
|
||||
assert list(inspect.signature(state.status_text).parameters) == [
|
||||
"workers_dict", "pending_list", "running_dict",
|
||||
]
|
||||
assert "legacy_timeouts_ignored" not in inspect.getsource(state.status_text)
|
||||
|
||||
|
||||
def test_no_runtime_or_settings_surface_still_names_either_key():
|
||||
"""The grep-class assertion. The N−1 and N−2 audit fixtures are the files
|
||||
that must keep them: frozen byte-copies of real v6.113.4 / v6.87.5 settings
|
||||
|
|
@ -188,14 +164,3 @@ def test_surface_scan_excludes_ignored_exports_but_checks_tracked_and_new_source
|
|||
test_no_runtime_or_settings_surface_still_names_either_key()
|
||||
(tmp_path / 'new.py').write_text('current = True')
|
||||
test_no_runtime_or_settings_surface_still_names_either_key()
|
||||
|
||||
|
||||
def test_the_ledger_row_is_not_a_second_dispatcher_for_the_status_line():
|
||||
"""Guards the shape of the removal itself: ``status_text`` must not have
|
||||
grown a replacement knob while losing the old one."""
|
||||
tree = ast.parse((REPO / "supervisor" / "state.py").read_text(encoding="utf-8"))
|
||||
fn = next(node for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "status_text")
|
||||
names = {node.value for node in ast.walk(fn)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str)}
|
||||
assert not any("TIMEOUT_SEC" in name for name in names)
|
||||
|
|
|
|||
|
|
@ -8,15 +8,16 @@ left to the maintainer.)
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_backoff_doubled_with_cap():
|
||||
"""Backoff stays exponential (x4 base) and per-class capped: 30s for
|
||||
generic retryable errors, 60s for transient provider classes (v6.28.0)."""
|
||||
src = (REPO / "ouroboros" / "loop_llm_call.py").read_text(encoding="utf-8")
|
||||
assert "2.0 ** attempt * 4" in src # doubled per-attempt backoff
|
||||
assert "min(2 ** attempt * 2, 30)" not in src # old value gone
|
||||
assert "_TRANSIENT_BACKOFF_CAP_SEC if is_transient else 30.0" in src
|
||||
generic retryable errors, the transient cap for provider classes."""
|
||||
from ouroboros.loop_llm_call import _TRANSIENT_BACKOFF_CAP_SEC, _retry_backoff_sec
|
||||
|
||||
assert _retry_backoff_sec(0, False) == 4.0
|
||||
assert _retry_backoff_sec(1, False) == 8.0
|
||||
assert _retry_backoff_sec(2, False) == 16.0
|
||||
assert _retry_backoff_sec(3, False) == 30.0
|
||||
assert _retry_backoff_sec(4, False) == 30.0
|
||||
assert _retry_backoff_sec(0, True) == min(4.0, _TRANSIENT_BACKOFF_CAP_SEC)
|
||||
assert _retry_backoff_sec(10, True) == float(_TRANSIENT_BACKOFF_CAP_SEC)
|
||||
|
|
|
|||
|
|
@ -558,10 +558,3 @@ class TestSupportedParametersFilter:
|
|||
)
|
||||
# Unknown model → cache miss → None → no stripping
|
||||
assert kwargs.get("temperature") == 0.2
|
||||
|
||||
def test_no_version_specific_sampling_gate_remains(self):
|
||||
import pathlib
|
||||
|
||||
llm_text = pathlib.Path(__file__).resolve().parents[1].joinpath("ouroboros", "llm.py").read_text(encoding="utf-8")
|
||||
assert "claude-opus-4-7" not in llm_text
|
||||
assert "claude-opus-4.7" not in llm_text
|
||||
|
|
|
|||
|
|
@ -80,24 +80,6 @@ def test_manual_request_uses_the_shared_typed_materializer(tmp_path, monkeypatch
|
|||
assert context.tools._ctx._pending_compaction is None
|
||||
|
||||
|
||||
def test_old_main_trigger_authorities_are_deleted():
|
||||
from pathlib import Path
|
||||
|
||||
source = Path("ouroboros/loop.py").read_text(encoding="utf-8")
|
||||
budget = Path("ouroboros/context_budget.py").read_text(encoding="utf-8")
|
||||
for symbol in (
|
||||
"EMERGENCY_COMPACTION_CHARS",
|
||||
"LOW_EMERGENCY_COMPACTION_CHARS",
|
||||
"COMPACTION_HYSTERESIS_REGION_GROWTH",
|
||||
"COMPACTION_HYSTERESIS_ROUNDS",
|
||||
"_compaction_floor_chars",
|
||||
"_emergency_keep_recent",
|
||||
):
|
||||
assert symbol not in source
|
||||
assert symbol not in budget
|
||||
assert "round_idx > 6" not in source
|
||||
|
||||
|
||||
# --- earlier capsules are re-folded only after the provider's own refusal ----------------------
|
||||
|
||||
def _long_earlier_capsule(run, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ def _read_network_wait_events(tmp_path):
|
|||
path = tmp_path / "events.jsonl"
|
||||
if not path.exists():
|
||||
return []
|
||||
rows = [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
||||
rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
return [row for row in rows if row.get("type") == "network_wait"]
|
||||
|
||||
|
||||
|
|
@ -645,7 +645,7 @@ def test_end_to_end_episode_through_real_retry_helper(tmp_path, monkeypatch):
|
|||
assert len(sleeps) == 2
|
||||
phases = [row["phase"] for row in _read_network_wait_events(tmp_path)]
|
||||
assert phases == ["entered", "waiting", "waiting", "recovered"]
|
||||
rows = [json.loads(line) for line in (tmp_path / "events.jsonl").read_text().splitlines() if line.strip()]
|
||||
rows = [json.loads(line) for line in (tmp_path / "events.jsonl").read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
api_errors = [row for row in rows if row.get("type") == "llm_api_error"]
|
||||
assert api_errors and all(row.get("error_kind") == "transport_unavailable" for row in api_errors)
|
||||
|
||||
|
|
@ -773,11 +773,14 @@ def test_finalize_now_during_episode_takes_no_resend_terminal_via_mailbox(tmp_pa
|
|||
monkeypatch.delenv("USE_LOCAL_FALLBACK", raising=False)
|
||||
registry = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path)
|
||||
notes = []
|
||||
start = time.monotonic()
|
||||
result, usage, trace = run_llm_loop(**_loop_kwargs(tmp_path, registry, notes))
|
||||
elapsed = time.monotonic() - start
|
||||
def forbidden_sleep(_seconds):
|
||||
pytest.fail("finalize_now must wake the episode before any backoff sleep")
|
||||
|
||||
# Patch this transport's clock namespace, leaving unrelated store/fixture waits alone.
|
||||
monkeypatch.setattr(loop_transport, "time", SimpleNamespace(
|
||||
monotonic=time.monotonic, sleep=forbidden_sleep))
|
||||
result, usage, trace = run_llm_loop(**_loop_kwargs(tmp_path, registry, notes))
|
||||
|
||||
assert elapsed < 3.5 # the wake check saw the control before the 4 s backoff: no sleep ran
|
||||
assert calls["n"] == 1 # the woken redial exited at the round top: zero further dials
|
||||
assert usage.get("execution_status") == "infra_failed"
|
||||
assert usage.get("reason_code") == "provider_unavailable"
|
||||
|
|
@ -885,7 +888,7 @@ def test_episode_ledger_evidence_has_zero_dispatched_paid_attempts(tmp_path, mon
|
|||
**_loop_kwargs(tmp_path, ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path), notes, llm=llm))
|
||||
|
||||
assert result == "done"
|
||||
rows = [json.loads(line) for line in (tmp_path / "events.jsonl").read_text().splitlines() if line.strip()]
|
||||
rows = [json.loads(line) for line in (tmp_path / "events.jsonl").read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
entered_idx = next(i for i, r in enumerate(rows) if r.get("type") == "network_wait" and r.get("phase") == "entered")
|
||||
last_wait_idx = max(i for i, r in enumerate(rows) if r.get("type") == "network_wait" and r.get("phase") == "waiting")
|
||||
window = rows[entered_idx:last_wait_idx + 1]
|
||||
|
|
|
|||
|
|
@ -179,9 +179,10 @@ def test_actual_exit_consumers_keep_blocked_discovery_visible_then_accept_exit(r
|
|||
import server
|
||||
from ouroboros import body_adoption
|
||||
from tests.test_body_adoption_consumers import EXITED
|
||||
from tests.body_candidate_support import make_serving, make_ctx, rich_commit, restart_receipt
|
||||
from tests.body_candidate_support import make_serving, make_ctx, rich_commit, restart_receipt, stub_adoption_evidence
|
||||
from ouroboros import body_candidate, body_switch
|
||||
|
||||
stub_adoption_evidence(monkeypatch) # this test exits maintenance, not the test battery
|
||||
serving = make_serving(root.parent / "body")
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "pro")
|
||||
# These are disposable repositories/data, never the installation.
|
||||
|
|
|
|||
|
|
@ -132,13 +132,6 @@ def test_mcp_css_defines_required_classes(settings_css: str) -> None:
|
|||
assert 'class="form-field ui-field"' in source
|
||||
|
||||
|
||||
def test_settings_ui_mcp_section_describes_hot_reload(settings_ui_source: str) -> None:
|
||||
"""The UI copy must communicate that MCP changes are hot-reloadable
|
||||
(it's the ergonomic difference vs A2A which requires restart)."""
|
||||
assert "Hot-reloadable" in settings_ui_source or "hot-reloadable" in settings_ui_source
|
||||
assert "untrusted third-party data" in settings_ui_source
|
||||
|
||||
|
||||
def test_mcp_ui_roundtrip_preserves_environment_and_unsupported_fields():
|
||||
"""Execute the real JS projection; this is not a browser/visual receipt."""
|
||||
node = _node_bin()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ retention; the compatibility entry point is intentionally non-destructive.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
|
@ -51,12 +50,6 @@ def test_maintenance_does_not_create_missing_journals_or_directories(tmp_path):
|
|||
assert not root.exists()
|
||||
|
||||
|
||||
def test_startup_prune_still_reaches_compatibility_entry_point():
|
||||
import ouroboros.server_maintenance as maintenance
|
||||
|
||||
assert "compact_memory_journal_snapshots" in inspect.getsource(maintenance._run_deferred_startup_prunes)
|
||||
|
||||
|
||||
def test_size_observation_does_not_follow_a_journal_symlink(tmp_path):
|
||||
target = tmp_path / "elsewhere"
|
||||
target.write_bytes(b"secret data")
|
||||
|
|
@ -81,12 +74,15 @@ def test_startup_event_publishes_normal_journal_sizes(tmp_path, monkeypatch):
|
|||
journal.write_bytes(b"full historical text\n")
|
||||
rows = []
|
||||
monkeypatch.setattr(maintenance, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(maintenance, "_STARTUP_PRUNES_OWED", [True])
|
||||
monkeypatch.setattr(maintenance, "_STARTUP_TREES_OWED", [False])
|
||||
monkeypatch.setattr(maintenance, "_STARTUP_SOURCE_PRUNES_OWED", [False])
|
||||
monkeypatch.setattr(maintenance, "_startup_worktree_prune", lambda: None)
|
||||
monkeypatch.setattr(maintenance, "_prune_delegated_snapshots", lambda: None)
|
||||
monkeypatch.setattr(state, "append_jsonl", lambda _path, row: rows.append(row))
|
||||
report = compact_memory_journal_snapshots(tmp_path)
|
||||
maintenance._prune_event("memory_journal_observation", ("journal_bytes", "errors"), report=report)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["report"]["journal_bytes"]["memory/knowledge_history.jsonl"] == len(b"full historical text\n")
|
||||
|
||||
maintenance._run_deferred_startup_prunes()
|
||||
|
||||
[observation] = [row for row in rows if row["type"] == "memory_journal_observation"]
|
||||
assert observation["report"]["journal_bytes"]["memory/knowledge_history.jsonl"] == len(b"full historical text\n")
|
||||
assert journal.read_bytes() == b"full historical text\n"
|
||||
# Pin the real startup caller, not only this unit invocation.
|
||||
source = inspect.getsource(maintenance._run_deferred_startup_prunes)
|
||||
assert '_prune_event("memory_journal_observation", ("journal_bytes", "errors")' in source
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ still nominates notes writes none.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
|
@ -211,4 +210,3 @@ def test_the_reader_less_knowledge_journal_is_no_longer_written(tmp_path):
|
|||
assert store.write_knowledge_note(_address(tmp_path), "# Alex\n\nFirst.").ok
|
||||
assert not (tmp_path / "memory" / "knowledge_journal.jsonl").exists()
|
||||
assert (tmp_path / "memory" / "knowledge_history.jsonl").exists()
|
||||
assert "knowledge_journal" not in inspect.getsource(store)
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
"""People are a subject of Ouroboros's memory, stated in its own prompts.
|
||||
|
||||
Nothing in the runtime prompts used to say that understanding a person is
|
||||
durable knowledge: SYSTEM.md scoped the knowledge base to operational facts,
|
||||
recipes and gotchas, the background checklist asked only for a gotcha, a recipe
|
||||
or a pattern, and reflection nominated "self-knowledge". The observable result
|
||||
on a live install was that everything ever written about the human had been
|
||||
dictated word for word, never noticed and never inferred.
|
||||
|
||||
These are vocabulary pins for the memory contract — which memory each kind of
|
||||
understanding has, and where it lives. They pin the words that carry each
|
||||
contract, not a model's behaviour; dropping one of these contracts is the defect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (REPO_ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _section(text: str, heading: str) -> str:
|
||||
"""The body of one `## `/`### ` section, whitespace-normalized."""
|
||||
marker = re.search(rf"^#{{2,3}} {re.escape(heading)}\s*$", text, re.MULTILINE)
|
||||
assert marker, f"section {heading!r} is gone"
|
||||
rest = text[marker.end():]
|
||||
end = re.search(r"^#{2,3} ", rest, re.MULTILINE)
|
||||
return " ".join((rest[: end.start()] if end else rest).split())
|
||||
|
||||
|
||||
def _claims(section: str) -> list:
|
||||
"""Sentence-ish units, lowercased: a contract may move between sentences."""
|
||||
return [part.strip() for part in re.split(r"(?<=[.;:])\s+", section.lower()) if part.strip()]
|
||||
|
||||
|
||||
def test_system_memory_section_makes_people_a_subject_of_knowledge():
|
||||
memory = _section(_read("prompts/SYSTEM.md"), "Memory")
|
||||
claims = _claims(memory)
|
||||
|
||||
# Knowledge is understanding of every kind, people explicitly included.
|
||||
assert any("people" in c and ("knowledge" in c or "understanding" in c) for c in claims)
|
||||
# Attribution: told me / observed / inferred are kept apart.
|
||||
assert any("infer" in c and ("told me" in c or "observed" in c) for c in claims)
|
||||
# A correction is situated evidence, not a standing rule.
|
||||
assert any("correction" in c and "standing rule" in c for c in claims)
|
||||
# Repeating an interpretation does not make it more true.
|
||||
assert any("interpretation" in c and ("one interpretation" in c or "not new" in c) for c in claims)
|
||||
# The authored summary is the resident face of a note.
|
||||
assert any("summary" in c and "index" in c for c in claims)
|
||||
# The overview is the shared orientation loaded into every context.
|
||||
assert any("overview" in c and "context" in c for c in claims)
|
||||
# Understanding of people is global, whatever room the work happens in.
|
||||
assert any("people" in c and "global" in c for c in claims)
|
||||
# What I learn about a person is written in the same turn, not left to a later summary.
|
||||
assert any("same turn" in c and "person" in c for c in claims)
|
||||
# The old scoping, which is what made the knowledge base operational-only.
|
||||
assert "Durable operational facts, recipes, and gotchas go to knowledge topics" not in memory
|
||||
|
||||
|
||||
def test_system_memory_section_keeps_its_earlier_obligations():
|
||||
"""The section gained clauses; it must not have lost the ones that were load
|
||||
bearing — provenance, the reserved index name, and dated external knowledge."""
|
||||
memory = _section(_read("prompts/SYSTEM.md"), "Memory")
|
||||
|
||||
assert "I distinguish known, stale, missing, and inferred" in memory
|
||||
assert "`knowledge_list` shows the topics" in memory
|
||||
assert "`knowledge/index-full.md` is a reserved internal name" in memory
|
||||
assert "I `knowledge_read` its topic first" in memory
|
||||
assert "stale unless recently verified" in memory
|
||||
|
||||
|
||||
def test_system_states_the_chronicle_contract_and_each_writer_in_one_home():
|
||||
"""The chronicle's writers in the mind's own prompt: the mind seals, marks and reads
|
||||
by address; the overview is the mind's; a Light helper drafts one page or part only
|
||||
while the wakes are off; an offer to fold the old retelling is settled by a global
|
||||
mark. A delegated child's start and its drafts live in Delegation, the
|
||||
self-contained assignment in Memory. The two claims the chronicle made false
|
||||
(nothing project-related hidden, read-only helpers writing nothing) are gone."""
|
||||
system = _read("prompts/SYSTEM.md")
|
||||
memory, delegation, tools = (_section(system, name) for name in ("Memory", "Delegation", "Tools"))
|
||||
claims = _claims(memory)
|
||||
|
||||
for tool in ("`chronicle_write`", "`memory_mark`", "`memory_read`"):
|
||||
assert tool in memory, tool
|
||||
assert any("overview" in c and "my own words" in c and "helpers do not write it" in c for c in claims)
|
||||
assert any("light helper" in c and "one page or part" in c and "wakes are off" in c for c in claims)
|
||||
assert any("fold" in c and "ordinary background task" in c for c in claims)
|
||||
assert any("global mark" in c and "`memory_mark`" in c for c in claims)
|
||||
# One home each: the child's start and drafts in Delegation, the assignment rule in Memory.
|
||||
assert "never my whole dialogue history" in delegation
|
||||
assert "drafts in its own name, which the integrating mind accepts or rejects" in delegation
|
||||
assert "drafts in its own name" not in memory
|
||||
assert "must stand on its own" in memory and "must stand on its own" not in delegation
|
||||
# The memory tools and the chronicle's own path are named where file writes are ruled out.
|
||||
assert "`chronicle_write`, `memory_mark`" in tools and "`memory/chronicle/*`" in tools
|
||||
assert "chronicle page and part drafts in their own name" in tools
|
||||
# Everything the host guarantees a child at start, its memory marks included (memory_view.ROLE_DEFAULTS).
|
||||
start = next(c for c in _claims(delegation) if "never my whole dialogue history" in c)
|
||||
assert all(part in start for part in ("room's page", "global memory marks", "whole assignment", "verbatim"))
|
||||
assert "which I accept or reject" not in delegation # a child that schedules a grandchild does not decide
|
||||
flat = " ".join(system.split())
|
||||
assert "nothing project-related is hidden" not in flat
|
||||
assert "Read-only helpers do not write" not in flat
|
||||
|
||||
|
||||
def test_system_names_the_natural_moments_to_seal_without_a_command_or_a_host_check():
|
||||
"""The sealing rule stands word for word; right after its first sentence one sentence names the natural
|
||||
moments to look at what has closed and seal it — a wake, the end of a root task — and that the view shows
|
||||
what is still unsealed and since when. It is an observation about moments, not a command and not a host
|
||||
check: no threshold, no reminder, no 'must'; the model decides (owner decision 2A)."""
|
||||
memory = _section(_read("prompts/SYSTEM.md"), "Memory")
|
||||
rule = ("People's words and my own replies stay verbatim while their conversation is open and fits; when a "
|
||||
"matter closes — by its sense, not by a calendar — or the host says it no longer fits, I seal it with "
|
||||
"`chronicle_write` as a page in my own words written from those rows, quoting exactly the decisive words "
|
||||
"and my promises; every line of the record says who wrote it — my reply, a child's report, a host fact, "
|
||||
"a helper's draft.")
|
||||
moments = ("A wake and the end of a root task are natural moments to look at what has closed since my last page "
|
||||
"— here and in the live rooms — and seal it; the view shows what is still unsealed and since when.")
|
||||
assert f"{rule} {moments} What must stay in view I mark with `memory_mark`;" in memory
|
||||
assert memory.count("natural moments") == 1
|
||||
assert "natural moments" not in _read("prompts/CONSCIOUSNESS.md") # one home: the wake text does not restate it
|
||||
# The standing pages line of the view is what "since when" points at: the status sentence about the old
|
||||
# retelling speaks of the story status, not of my pages, so the two do not contradict.
|
||||
claims = _claims(memory)
|
||||
assert any("story status" in c and "old retelling" in c and "not yet all folded" in c for c in claims)
|
||||
assert not any("no page" in c or "pages sealed" in c for c in claims)
|
||||
|
||||
|
||||
def test_system_learns_a_person_through_the_relationship_not_only_on_demand():
|
||||
human = _section(_read("prompts/SYSTEM.md"), "Environment and My Human")
|
||||
claims = _claims(human)
|
||||
|
||||
# My human is a relationship, and the neutral form before acquaintance stays.
|
||||
assert any("my human" in c and "relationship" in c for c in claims)
|
||||
assert "I do not know their name or personal profile by default" in human
|
||||
# Conversation, shared work and — by its own judgment — public work.
|
||||
assert any("conversation" in c or "work we do together" in c for c in claims)
|
||||
assert any("public work" in c for c in claims)
|
||||
# A plain question is allowed, the name first (owner decision 10A).
|
||||
assert any(("ask" in c or "question" in c) and "name" in c for c in claims)
|
||||
# What is understood lives in knowledge, under the person's name.
|
||||
assert any("name" in c and ("knowledge" in c or "understand" in c) for c in claims)
|
||||
# "owner" is authority, never the person (owner decision 2A).
|
||||
assert any("owner" in c and "authority" in c for c in claims)
|
||||
# The dead end this replaced: ask, learn it, and never revisit it.
|
||||
assert "if I need a name or preference, I ask and then learn it in memory" not in human
|
||||
|
||||
|
||||
def test_memory_vocabulary_is_not_dressed_up_as_tool_names():
|
||||
"""Backticked `summary`/`overview` would read as tool identifiers both to the
|
||||
model and to the prompt identifier audit in tests/test_docs_sync.py."""
|
||||
for rel in ("prompts/SYSTEM.md", "prompts/CONSCIOUSNESS.md"):
|
||||
text = _read(rel)
|
||||
for word in ("summary", "overview"):
|
||||
assert f"`{word}`" not in text, f"{rel} backticks {word}"
|
||||
|
||||
|
||||
def test_the_people_memory_contract_has_exactly_one_home():
|
||||
"""The wake-up message is an ordinary Main turn's USER text, so it runs on the
|
||||
system prompt that already states this contract. It used to restate it — which
|
||||
made the same obligation editable in two places and drifted the moment one
|
||||
changed. The contract is pinned above, in its single home; what is pinned here
|
||||
is that the wake does not carry a second copy of it (P7 one home per fact)."""
|
||||
memory = _section(_read("prompts/SYSTEM.md"), "Memory")
|
||||
claims = _claims(memory)
|
||||
|
||||
# Still exactly the obligations the wake used to duplicate.
|
||||
assert any("revise" in c and ("in place" in c or "same turn" in c) for c in claims)
|
||||
assert any("one interpretation" in c for c in claims)
|
||||
|
||||
wake = " ".join(_read("prompts/CONSCIOUSNESS.md").split()).lower()
|
||||
for duplicated in ("people you talk with", "rather than minting a new one",
|
||||
"knowledge_read", "knowledge_write", "contradictions"):
|
||||
assert duplicated not in wake, f"the wake restates {duplicated!r}"
|
||||
|
||||
|
||||
def test_wake_template_addresses_its_human_not_a_user():
|
||||
consciousness = _read("prompts/CONSCIOUSNESS.md")
|
||||
|
||||
assert "Speak to your human" in consciousness
|
||||
assert "your human" in consciousness and "the user" not in consciousness
|
||||
|
|
@ -236,19 +236,3 @@ def test_data_read_missing_non_memory_path_uses_narrower_sentinel(tmp_path):
|
|||
assert "DATA_NOT_YET_CREATED" in result
|
||||
assert "lazily on first write" not in result
|
||||
assert "not guaranteed" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section header annotation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_memory_section_headers_indicate_already_loaded():
|
||||
"""Inspect the source: build_memory_sections must annotate identity
|
||||
and scratchpad so the agent doesn't re-read them."""
|
||||
import inspect
|
||||
import ouroboros.context as ctx_mod
|
||||
src = inspect.getsource(ctx_mod.build_memory_sections)
|
||||
assert "memory/scratchpad.md" in src
|
||||
assert "memory/identity.md" in src
|
||||
assert "already loaded" in src
|
||||
assert "do not re-read" in src
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue